+
+
+
+
+
+
diff --git a/frontend/src/tutorial/controller.ts b/frontend/src/tutorial/controller.ts
new file mode 100644
index 0000000..9b522d0
--- /dev/null
+++ b/frontend/src/tutorial/controller.ts
@@ -0,0 +1,193 @@
+import { ref, watch } from 'vue'
+import { eventBus } from '@/common/eventBus'
+import { stepRegistry, type StepDef } from './steps'
+
+export type AnchorSource = HTMLElement | (() => HTMLElement | null) | null
+
+export interface ActiveStep {
+ def: StepDef
+ anchor: AnchorSource
+}
+
+export const tutorialEnabled = ref(true)
+export const tutorialProgress = ref>({})
+export const tutorialReady = ref(false)
+export const activeStep = ref(null)
+export const sessionSkipped = ref(false)
+
+const queue: ActiveStep[] = []
+let hydrated = false
+
+function applyDevOverrides() {
+ if (typeof window === 'undefined') return
+ if (!import.meta.env.DEV) return
+ const params = new URLSearchParams(window.location.search)
+ if (params.get('tutorial') === 'off') {
+ tutorialEnabled.value = false
+ }
+ if (params.get('tutorial') === 'reset') {
+ void resetAllProgress()
+ }
+}
+
+export function hydrateFromProfile(profile: {
+ tutorial_enabled?: boolean
+ tutorial_progress?: Record
+}) {
+ if (typeof profile.tutorial_enabled === 'boolean') {
+ tutorialEnabled.value = profile.tutorial_enabled
+ }
+ if (profile.tutorial_progress && typeof profile.tutorial_progress === 'object') {
+ tutorialProgress.value = { ...profile.tutorial_progress }
+ }
+ tutorialReady.value = true
+ if (!hydrated) {
+ hydrated = true
+ applyDevOverrides()
+ }
+ // Re-evaluate queue: any step now-seen should be dropped.
+ for (let i = queue.length - 1; i >= 0; i--) {
+ const entry = queue[i]
+ if (entry && !shouldShowStep(entry.def.id)) queue.splice(i, 1)
+ }
+ if (activeStep.value && !shouldShowStep(activeStep.value.def.id)) {
+ activeStep.value = null
+ promote()
+ }
+}
+
+export function shouldShowStep(id: string): boolean {
+ if (!tutorialEnabled.value) return false
+ if (sessionSkipped.value) return false
+ if (tutorialProgress.value[id]) return false
+ return true
+}
+
+function resolveAnchor(anchor: AnchorSource): HTMLElement | null {
+ if (!anchor) return null
+ if (typeof anchor === 'function') return anchor()
+ return anchor
+}
+
+function promote() {
+ if (activeStep.value) return
+ while (queue.length > 0) {
+ const next = queue.shift()!
+ if (!shouldShowStep(next.def.id)) continue
+ // If anchor is missing, try the registry fallback selector.
+ if (next.anchor === null && next.def.anchorSelector) {
+ const sel = next.def.anchorSelector
+ next.anchor = () => document.querySelector(sel) as HTMLElement | null
+ }
+ if (next.anchor !== null) {
+ const el = resolveAnchor(next.anchor)
+ if (!el) continue
+ }
+ activeStep.value = next
+ return
+ }
+}
+
+/**
+ * Queue a step to be shown. Idempotent: if already seen or session-skipped, no-op.
+ * If anchor is provided but the element is missing at promotion time, the step is dropped.
+ * If anchor is null, the step renders as a centered modal-style card.
+ */
+export function maybeShow(id: string, anchor: AnchorSource = null) {
+ if (!stepRegistry[id]) {
+ if (import.meta.env.DEV) console.warn(`[tutorial] Unknown step id: ${id}`)
+ return
+ }
+ if (!shouldShowStep(id)) return
+ if (activeStep.value?.def.id === id) return
+ if (queue.some((q) => q.def.id === id)) return
+ queue.push({ def: stepRegistry[id], anchor })
+ promote()
+}
+
+export function dismissActive(markSeen = true) {
+ const current = activeStep.value
+ if (!current) return
+ activeStep.value = null
+ if (markSeen) {
+ void markStepSeen(current.def.id)
+ if (current.def.next) {
+ const nextDef = stepRegistry[current.def.next]
+ if (nextDef && shouldShowStep(nextDef.id)) {
+ queue.unshift({ def: nextDef, anchor: null })
+ }
+ }
+ }
+ promote()
+}
+
+export function skipSession() {
+ const current = activeStep.value
+ sessionSkipped.value = true
+ queue.length = 0
+ activeStep.value = null
+ if (current) void markStepSeen(current.def.id)
+}
+
+export async function markStepSeen(id: string): Promise {
+ // Optimistic — local first, then persist.
+ if (tutorialProgress.value[id]) return
+ tutorialProgress.value = { ...tutorialProgress.value, [id]: true }
+ try {
+ await fetch('/api/user/tutorial-progress', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ step_id: id, seen: true }),
+ })
+ } catch (e) {
+ if (import.meta.env.DEV) console.warn('[tutorial] Failed to persist step', id, e)
+ }
+}
+
+export async function resetAllProgress(): Promise {
+ tutorialProgress.value = {}
+ sessionSkipped.value = false
+ try {
+ await fetch('/api/user/tutorial-progress', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ reset: true }),
+ })
+ } catch (e) {
+ if (import.meta.env.DEV) console.warn('[tutorial] Failed to reset progress', e)
+ }
+}
+
+export async function setTutorialEnabled(on: boolean): Promise {
+ tutorialEnabled.value = on
+ if (!on) {
+ queue.length = 0
+ activeStep.value = null
+ }
+ try {
+ await fetch('/api/user/tutorial-progress', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ enabled: on }),
+ })
+ } catch (e) {
+ if (import.meta.env.DEV) console.warn('[tutorial] Failed to set enabled', e)
+ }
+}
+
+let profileUpdatedBound = false
+export function bindProfileUpdatedListener(refetch: () => Promise | void) {
+ if (profileUpdatedBound) return
+ profileUpdatedBound = true
+ eventBus.on('profile_updated', () => {
+ void refetch()
+ })
+}
+
+// When the active step is dismissed externally (e.g. anchor unmounts), drain the queue.
+watch(activeStep, (val) => {
+ if (val === null) promote()
+})
diff --git a/frontend/src/tutorial/steps.ts b/frontend/src/tutorial/steps.ts
new file mode 100644
index 0000000..513933b
--- /dev/null
+++ b/frontend/src/tutorial/steps.ts
@@ -0,0 +1,185 @@
+export type Placement = 'auto' | 'below' | 'above' | 'left' | 'right'
+
+export interface StepDef {
+ id: string
+ title: string
+ body: string
+ /** Chain another step to fire after this one is dismissed. */
+ next?: string
+ placement?: Placement
+ /** Primary button label. Defaults to "Got it" (or "Next" when `next` is set). */
+ ctaLabel?: string
+ /**
+ * Fallback CSS selector used to resolve the anchor element when a step is
+ * triggered via chaining (no explicit anchor passed). Optional — if not set
+ * and no anchor is provided, the step renders centered with no spotlight.
+ */
+ anchorSelector?: string
+}
+
+/**
+ * Tutorial step registry. To add a new step:
+ * 1. Add an entry here.
+ * 2. In the host component that owns the anchor element, call
+ * `tutorial.maybeShow('your-id', anchorRefOrSelectorFn)`.
+ */
+export const stepRegistry: Record = {
+ // ── Forced 3-step intro ──────────────────────────────────────────────────
+ 'setup-parent-pin': {
+ id: 'setup-parent-pin',
+ title: 'Welcome! Set up your parent PIN',
+ body: 'Tap your avatar to set up a 4–6 digit PIN. The PIN keeps parent mode (where you add chores and rewards) safe from little fingers.',
+ placement: 'below',
+ ctaLabel: 'Got it',
+ },
+ 'create-child': {
+ id: 'create-child',
+ title: 'Add your first child',
+ body: "Let's add your first kiddo. Tap the + button to create a profile — you can pick a fun avatar together.",
+ placement: 'above',
+ ctaLabel: 'Got it',
+ },
+ 'create-chore': {
+ id: 'create-chore',
+ title: 'Create your first chore',
+ body: 'Tap + to add a chore — give it a name, choose points, and pick a picture so your child can spot it easily.',
+ placement: 'above',
+ ctaLabel: 'Got it',
+ },
+
+ // ── Create-flow JIT hints ────────────────────────────────────────────────
+ 'create-chore-image': {
+ id: 'create-chore-image',
+ title: 'Pick a picture',
+ body: 'Tap + to choose from your photos, or the camera to snap a new one. A clear picture helps younger kids recognize each chore.',
+ placement: 'above',
+ },
+ 'create-chore-schedule': {
+ id: 'create-chore-schedule',
+ title: 'Set when it happens',
+ body: 'Tap the days this chore should appear. Set a deadline if it needs to be done by a certain time, or leave it as Anytime.',
+ placement: 'below',
+ },
+ 'create-kindness': {
+ id: 'create-kindness',
+ title: 'Kindness tasks',
+ body: 'Kindness tasks reward thoughtful behavior — sharing, helping a sibling, saying please. Give it a name and points.',
+ placement: 'auto',
+ },
+ 'create-penalty': {
+ id: 'create-penalty',
+ title: 'Penalties',
+ body: 'Penalties subtract points when rules are broken. Use them sparingly — they work best alongside lots of positive tasks.',
+ placement: 'auto',
+ },
+ 'create-routine': {
+ id: 'create-routine',
+ title: 'Routines',
+ body: "Routines bundle a sequence of small tasks — like a bedtime routine. Add each task below, and your child checks them off one by one.",
+ placement: 'auto',
+ },
+ 'create-routine-add-task': {
+ id: 'create-routine-add-task',
+ title: 'Add tasks to the routine',
+ body: 'Tap to add each step. Order them the way you want your child to do them — they can be reordered later by dragging.',
+ placement: 'above',
+ },
+ 'create-reward': {
+ id: 'create-reward',
+ title: 'Create your first reward',
+ body: "Rewards are what your child can spend points on — screen time, treats, a special outing. Tap + to add one.",
+ placement: 'above',
+ },
+
+ // ── Per-tab / navigation hints ───────────────────────────────────────────
+ 'notification-click': {
+ id: 'notification-click',
+ title: 'Tap to review',
+ body: "We ping you when a chore is finished or a reward is requested. Tap a notification to jump straight to it and approve or reject.",
+ placement: 'below',
+ },
+
+ // ── Per-child view ───────────────────────────────────────────────────────
+ 'select-child': {
+ id: 'select-child',
+ title: "This is your child's page",
+ body: 'Here you can assign chores, rewards, and routines, see their points, and review their progress. Scroll down to see each section.',
+ placement: 'auto',
+ next: 'assign-chore',
+ ctaLabel: 'Next',
+ },
+ 'assign-chore': {
+ id: 'assign-chore',
+ title: 'Assign chores',
+ body: "Tap to pick which chores this child does. Assigned chores show up in their view. You can give the same chore to multiple kids.",
+ placement: 'above',
+ next: 'assign-reward',
+ ctaLabel: 'Next',
+ anchorSelector: '.assign-buttons button:nth-child(1)',
+ },
+ 'assign-reward': {
+ id: 'assign-reward',
+ title: 'Assign rewards',
+ body: "Pick which rewards this child can spend points on. They'll only see rewards you assign here.",
+ placement: 'above',
+ next: 'assign-routine',
+ ctaLabel: 'Next',
+ anchorSelector: '.assign-buttons button:nth-child(3)',
+ },
+ 'assign-routine': {
+ id: 'assign-routine',
+ title: 'Assign routines',
+ body: "Routines you assign show up on this child’s page as a checklist they can work through, step by step.",
+ placement: 'above',
+ anchorSelector: '.assign-buttons button:nth-child(2)',
+ },
+
+ // ── Kebab menus ──────────────────────────────────────────────────────────
+ 'child-kebab': {
+ id: 'child-kebab',
+ title: "Quick actions",
+ body: "Edit the child's name or photo, clear their points, or remove them entirely. These actions only affect this one kid.",
+ placement: 'below',
+ },
+ 'chore-kebab': {
+ id: 'chore-kebab',
+ title: 'Chore actions',
+ body: 'Edit points, change when the chore appears, or open the schedule. New options can appear here depending on the chore’s status.',
+ placement: 'auto',
+ },
+ 'chore-kebab-extend-time': {
+ id: 'chore-kebab-extend-time',
+ title: 'Extend the deadline',
+ body: "When a chore runs late, this option lets you give your child more time for the rest of today only — no need to reschedule.",
+ placement: 'auto',
+ },
+ 'chore-kebab-reset': {
+ id: 'chore-kebab-reset',
+ title: 'Reset a completed chore',
+ body: "Already-completed today? Reset lets your child do it again today (points are kept). Useful for chores you want done twice.",
+ placement: 'auto',
+ },
+
+ // ── Child-mode tour (parent learns to teach) ─────────────────────────────
+ 'child-mode-overview': {
+ id: 'child-mode-overview',
+ title: "What your child sees",
+ body: "This is your child's home screen. They tap their own card to see their chores, routines, and rewards — just like you've been setting up.",
+ placement: 'auto',
+ anchorSelector: '.grid',
+ },
+
+ // ── Status badges ────────────────────────────────────────────────────────
+ 'status-too-late': {
+ id: 'status-too-late',
+ title: 'Too late',
+ body: "This chore passed its deadline today. You can extend it from the kebab menu, or let it expire and try again tomorrow.",
+ placement: 'auto',
+ },
+ 'status-pending': {
+ id: 'status-pending',
+ title: 'Waiting for your review',
+ body: "Pending means your child marked this done and is waiting for you to give it a thumbs-up. Tap to approve or reject.",
+ placement: 'auto',
+ },
+}