Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions frontend/src/lib/components/home/TutorialBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,86 @@
import { goto } from '$app/navigation'
import { sendUserToast, type ToastAction } from '$lib/toast'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { skipAllTodos, syncTutorialsTodos } from '$lib/tutorialUtils'
import {
skipAllTodos,
syncTutorialsTodos,
TUTORIAL_BANNER_DISMISSED_KEY
} from '$lib/tutorialUtils'
import { tutorialsToDo, userStore } from '$lib/stores'
import { TUTORIALS_CONFIG } from '$lib/tutorials/config'
import { hasRoleAccess } from '$lib/tutorials/roleUtils'
import { onMount } from 'svelte'

const DISMISSED_KEY = 'tutorial_banner_dismissed'
let isDismissed = $state(false)

onMount(() => {
// Check if banner has been dismissed
isDismissed = getLocalSetting(DISMISSED_KEY) === 'true'
/**
* Get all tutorial indexes that are accessible to the current user based on their role.
* Automatically recomputes when $userStore changes.
*/
const accessibleTutorialIndexes = $derived.by(() => {
const indexes = new Set<number>()
const user = $userStore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Svelte 5 Best Practice: Consider using $derived for this computation since it depends on reactive state ($userStore). This would make the reactivity more explicit and efficient:

const accessibleTutorialIndexes = $derived.by(() => {
  const indexes = new Set<number>()
  const user = $userStore

  for (const tab of Object.values(TUTORIALS_CONFIG)) {
    if (!hasRoleAccess(user, tab.roles)) {
      continue
    }
    for (const tutorial of tab.tutorials) {
      if (tutorial.index !== undefined && hasRoleAccess(user, tutorial.roles)) {
        indexes.add(tutorial.index)
      }
    }
  }
  return indexes
})

This way the set is automatically recomputed when $userStore changes, and you can reference it directly in onMount without calling a function.


for (const tab of Object.values(TUTORIALS_CONFIG)) {
// Check if user has access to this tab category
if (!hasRoleAccess(user, tab.roles)) {
continue
}

for (const tutorial of tab.tutorials) {
// Check if tutorial has an index and user has access to it
if (tutorial.index !== undefined && hasRoleAccess(user, tutorial.roles)) {
indexes.add(tutorial.index)
}
}
}
return indexes
})

onMount(async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential Issue: This onMount is now async, but you're not handling potential errors if syncTutorialsTodos() fails. Consider adding error handling:

onMount(async () => {
  try {
    await syncTutorialsTodos()
    // ... rest of logic
  } catch (error) {
    console.error('Failed to sync tutorial progress:', error)
    // Fallback to manual dismissal check only
    isDismissed = getLocalSetting(DISMISSED_KEY) === 'true'
  }
})

This prevents the banner from being stuck in a loading state if the API call fails.

try {
// Sync tutorial progress from backend first
await syncTutorialsTodos()

// Check if banner has been manually dismissed
const manuallyDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'

// Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above
// Filter tutorialsToDo to only include tutorials accessible to the user
const remainingAccessibleTutorials = $tutorialsToDo.filter((index) =>
accessibleTutorialIndexes.has(index)
)

// Check if all accessible tutorials are completed
const allTutorialsCompleted = remainingAccessibleTutorials.length === 0

// Dismiss banner if manually dismissed OR all accessible tutorials completed
if (manuallyDismissed || allTutorialsCompleted) {
isDismissed = true
// Set localStorage when all tutorials are completed to persist dismissal
// Note: This will re-set the key on every page load if localStorage is cleared
// but tutorials remain completed in backend. This is intentional - the banner
// should stay hidden if tutorials are completed, regardless of localStorage state.
if (allTutorialsCompleted) {
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
}
}
} catch (error) {
console.error('Failed to sync tutorial progress:', error)
// Fallback to manual dismissal check only if API call fails
isDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
}
})

async function handleSkipAllTutorials() {
await skipAllTodos()
await syncTutorialsTodos()
storeLocalSetting(DISMISSED_KEY, 'true')
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
isDismissed = true
}

function dismissBanner() {
storeLocalSetting(DISMISSED_KEY, 'true')
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
isDismissed = true

const actions: ToastAction[] = [
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/lib/tutorialUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { tutorialsToDo, skippedAll } from './stores'
import { UserService } from './gen'
import { TUTORIALS_CONFIG } from './tutorials/config'

/**
* LocalStorage key for tracking if the tutorial banner has been dismissed.
* Shared between tutorialUtils and TutorialBanner component.
*/
export const TUTORIAL_BANNER_DISMISSED_KEY = 'tutorial_banner_dismissed'

/**
* Get the maximum tutorial index from the config.
* This ensures we don't hardcode the max ID and it automatically updates when tutorials are added.
Expand Down Expand Up @@ -64,6 +70,12 @@ export async function skipAllTodos() {
}
tutorialsToDo.set([])
skippedAll.set(true)

// Dismiss the tutorial banner when all tutorials are skipped
if (typeof window !== 'undefined') {
localStorage.setItem(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
}

await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
}

Expand Down
4 changes: 1 addition & 3 deletions frontend/src/routes/(root)/(logged)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,7 @@
{/if}
</PageHeader>

{#if !$userStore?.operator}
<TutorialBanner />
{/if}
<TutorialBanner />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional Change - Operators Can Now See Banner: This removes the check that was hiding the tutorial banner from operators.

Question: Is this intentional? Looking at the commit message "Allow new operator so see the tutorial banner", it seems intentional, but worth clarifying:

  1. Do operators have tutorials accessible to them in TUTORIALS_CONFIG?
  2. If operators have no accessible tutorials, the banner will show but be immediately dismissed by the role-based logic in TutorialBanner.svelte

If this is intentional to show operators the banner (perhaps they have their own tutorials), this is fine. Otherwise, you might want to keep the conditional but move the role logic to inside TutorialBanner.svelte for consistency.


{#if !$userStore?.operator}
<div class="w-full overflow-auto scrollbar-hidden pb-2">
Expand Down