From d147bd6f27e9b6c2228b4c14ee476c2e2a5a15b8 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Tue, 26 May 2026 16:54:45 -0400 Subject: [PATCH] feat(tutorial): implement comprehensive tutorial system with step guidance - Added tutorial controller to manage tutorial state and progress. - Introduced HelpButton component for contextual help throughout the application. - Created various tutorial steps for onboarding and feature guidance. - Integrated tutorial prompts in multiple components (ChildrenListView, LoginButton, ScheduleModal, etc.) to enhance user experience. - Implemented logic to show tutorials based on user actions and state. - Added functionality to dismiss and skip tutorial sessions. - Established a mechanism to hydrate tutorial state from user profile. --- backend/api/user_api.py | 33 ++ backend/models/user.py | 6 + frontend/src/App.vue | 4 + frontend/src/common/models.ts | 2 + frontend/src/components/child/ParentView.vue | 68 +++ .../notification/NotificationView.vue | 12 +- .../src/components/profile/UserProfile.vue | 113 +++++ frontend/src/components/reward/RewardView.vue | 16 +- .../components/routine/RoutineEditView.vue | 8 + .../components/shared/ChildrenListView.vue | 28 +- .../src/components/shared/LoginButton.vue | 22 + .../src/components/shared/ScheduleModal.vue | 10 +- frontend/src/components/task/ChoreView.vue | 16 +- .../src/components/task/KindnessEditView.vue | 2 + .../src/components/task/PenaltyEditView.vue | 2 + frontend/src/components/utils/ImagePicker.vue | 5 + frontend/src/layout/ChildLayout.vue | 12 + frontend/src/layout/ParentLayout.vue | 12 + frontend/src/tutorial/HelpButton.vue | 92 ++++ .../src/tutorial/TutorialChildModeOffer.vue | 100 +++++ frontend/src/tutorial/TutorialOverlay.vue | 402 ++++++++++++++++++ frontend/src/tutorial/controller.ts | 193 +++++++++ frontend/src/tutorial/steps.ts | 185 ++++++++ 23 files changed, 1338 insertions(+), 5 deletions(-) create mode 100644 frontend/src/tutorial/HelpButton.vue create mode 100644 frontend/src/tutorial/TutorialChildModeOffer.vue create mode 100644 frontend/src/tutorial/TutorialOverlay.vue create mode 100644 frontend/src/tutorial/controller.ts create mode 100644 frontend/src/tutorial/steps.ts diff --git a/backend/api/user_api.py b/backend/api/user_api.py index 11caf6d..1efd949 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -49,6 +49,8 @@ def get_profile(): 'image_id': user.image_id, 'email_digest_enabled': user.email_digest_enabled, 'push_notifications_enabled': user.push_notifications_enabled, + 'tutorial_enabled': user.tutorial_enabled, + 'tutorial_progress': user.tutorial_progress or {}, }), 200 @user_api.route('/user/profile', methods=['PUT']) @@ -109,6 +111,37 @@ def update_profile(): return jsonify({'message': 'Profile updated'}), 200 +@user_api.route('/user/tutorial-progress', methods=['PATCH']) +def update_tutorial_progress(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + user = get_current_user() + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + data = request.get_json() or {} + + if data.get('reset') is True: + user.tutorial_progress = {} + elif 'enabled' in data: + user.tutorial_enabled = bool(data.get('enabled')) + elif 'step_id' in data: + step_id = str(data.get('step_id') or '').strip() + if not step_id: + return jsonify({'error': 'Missing step_id'}), 400 + progress = dict(user.tutorial_progress or {}) + progress[step_id] = bool(data.get('seen', True)) + user.tutorial_progress = progress + else: + return jsonify({'error': 'No-op'}), 400 + + users_db.update(user.to_dict(), UserQuery.email == user.email) + send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id))) + return jsonify({ + 'tutorial_enabled': user.tutorial_enabled, + 'tutorial_progress': user.tutorial_progress, + }), 200 + @user_api.route('/user/image', methods=['PUT']) def update_image(): user_id = get_validated_user_id() diff --git a/backend/models/user.py b/backend/models/user.py index b9e5ab7..91eb845 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -25,6 +25,8 @@ class User(BaseModel): timezone: str | None = None email_digest_enabled: bool = True push_notifications_enabled: bool = True + tutorial_enabled: bool = True + tutorial_progress: dict = field(default_factory=dict) @classmethod def from_dict(cls, d: dict): @@ -51,6 +53,8 @@ class User(BaseModel): timezone=d.get('timezone'), email_digest_enabled=d.get('email_digest_enabled', True), push_notifications_enabled=d.get('push_notifications_enabled', True), + tutorial_enabled=d.get('tutorial_enabled', True), + tutorial_progress=d.get('tutorial_progress', {}) or {}, id=d.get('id'), created_at=d.get('created_at'), updated_at=d.get('updated_at') @@ -82,5 +86,7 @@ class User(BaseModel): 'timezone': self.timezone, 'email_digest_enabled': self.email_digest_enabled, 'push_notifications_enabled': self.push_notifications_enabled, + 'tutorial_enabled': self.tutorial_enabled, + 'tutorial_progress': self.tutorial_progress, }) return base diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8628eaa..8bd5d04 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,10 +1,14 @@ + + diff --git a/frontend/src/tutorial/TutorialChildModeOffer.vue b/frontend/src/tutorial/TutorialChildModeOffer.vue new file mode 100644 index 0000000..9d759e2 --- /dev/null +++ b/frontend/src/tutorial/TutorialChildModeOffer.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/tutorial/TutorialOverlay.vue b/frontend/src/tutorial/TutorialOverlay.vue new file mode 100644 index 0000000..5f66052 --- /dev/null +++ b/frontend/src/tutorial/TutorialOverlay.vue @@ -0,0 +1,402 @@ + + + + + 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', + }, +}