- Introduced a modular tutorial layer to guide new parents through the app setup process. - Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation). - Added just-in-time contextual hints for various features as users encounter them. - Persisted user progress on the backend with new fields in the User model. - Created a new tutorial controller and step registry in the frontend for managing tutorial states. - Added Help button for easy access to tutorial tips and a restart option in the user profile. - Ensured accessibility and mobile responsiveness for the tutorial overlay. - Included tests for backend and frontend functionalities related to the tutorial.
13 KiB
Tutorial / Onboarding Mode
Context
New parents signing up for the chore app land on an empty screen with no guidance. Many will not be especially technical — a busy parent setting things up on a phone needs to be shown where to tap, what each control does, and what states like TOO LATE / PENDING mean, without feeling badgered. Today there is no onboarding, no first-time tips, no contextual help.
The goal is a modular tutorial layer that:
- Walks users through the essentials (PIN → child → chore) so the app is never empty after first use.
- Surfaces contextual hints just-in-time when users reach a feature for the first time (image picker, kebab menus, scheduler, status badges, notifications, assignment, etc.).
- Persists progress per-user on the backend so it syncs across devices and is dismissed only once.
- Can be reset or disabled from the profile page.
- Is friction-light: visible Skip, one card at a time outside the intro, a permanent
?button so users can re-trigger help themselves. - Is extensible — adding a tutorial for a future feature is roughly a one-line call + a registry entry.
Approach (locked decisions)
- Cadence: brief forced 3-step intro after first sign-in (PIN setup → first child → first chore), then everything else is just-in-time.
- Mobile: bottom-sheet card on screens ≤600px, floating tooltip on desktop. Spotlight cut-out is shared.
- Skip behaviour: a single Skip suppresses all hints for the session; user re-engages via the per-screen
?button or by resetting from the profile. - State storage:
tutorial_progress: dict[str, bool]andtutorial_enabled: boolon theUsermodel, broadcast via existingPROFILE_UPDATEDSSE event. - Step authoring: imperative
tutorial.maybeShow('step-id', anchorRef)fromonMounted/watchin each host component, with step copy in a single registry file.
Architecture
Backend
backend/models/user.py— addtutorial_enabled: bool = Trueandtutorial_progress: dict = field(default_factory=dict)alongsideemail_digest_enabled. Updatefrom_dict/to_dict.backend/api/user_api.py— addPATCH /user/tutorial-progressthat accepts:{ "step_id": "...", "seen": true }— mark one step{ "reset": true }— clear the dict{ "enabled": false }— toggle Each branch emitsEventType.PROFILE_UPDATED(already wired) so other tabs / devices stay in sync.
Frontend — new directory frontend/src/tutorial/
-
controller.ts— module-levelref()s (matches the no-Pinia pattern fromstores/auth.ts):export const tutorialEnabled = ref(true) export const tutorialProgress = ref<Record<string, boolean>>({}) export const activeStep = ref<ActiveStep | null>(null) export function shouldShowStep(id: string): boolean export function maybeShow(id: string, anchor: HTMLElement | (() => HTMLElement | null)): void export async function markStepSeen(id: string): Promise<void> // PATCH + optimistic export async function resetAllProgress(): Promise<void> export async function setTutorialEnabled(on: boolean): Promise<void>Hydrated from
/user/profile(extend the existing fetch inLoginButton.vue); re-hydrated onprofile_updatedSSE. -
steps.ts— single registry of step copy/config:export interface StepDef { id: string title: string body: string next?: string // chains a tour placement?: 'auto' | 'below' | 'above' ctaLabel?: string // defaults "Got it" } export const stepRegistry: Record<string, StepDef> = { … }Step IDs follow flat kebab-case:
setup-parent-pin,create-child,create-chore,create-chore-image-photo,create-chore-image-camera,create-chore-schedule,chore-kebab,chore-kebab-extend-time,status-too-late,status-pending,notification-click,assign-chore,child-mode-tour-offer, etc. -
TutorialOverlay.vue— mounted once inApp.vue. WatchesactiveStep. Renders:- Four-rectangle dim (top/left/right/bottom of the anchor's bbox) with
rgba(0,0,0,0.55). - A coach-mark card with
title,body, Skip tour + primary action button. - Desktop: tooltip positioned by
getBoundingClientRect()+ viewport clamping (nofloating-ui— same hand-rolled pattern as the existingTimePickerPopover.vue). - Mobile (
window.matchMedia('(max-width: 600px)')): card docks as a bottom sheet with a chevron pointing toward the spotlight. - Re-positions on
resize, capture-phasescroll, andResizeObserveron the card. role="dialog", focus trap, Esc to dismiss,aria-live="polite"for announcements,prefers-reduced-motionkills animations.
- Four-rectangle dim (top/left/right/bottom of the anchor's bbox) with
-
HelpButton.vue— a small?icon placed in theParentLayouttopbar (andChildLayout). On tap, re-fires the most relevant step for the current route from arouteToStepmap. This is the user's escape hatch — critical for the non-technical audience.
Triggering — three sources, all routed through maybeShow
- Route entered —
router.afterEachincontroller.tsconsults a smallrouteTriggers: Record<RouteName, string>map (used fortab-tasks,tab-rewards,tab-notifications, …). - Element first rendered — host component calls
maybeShowinonMountedorwatchEffectonce its anchorrefis bound. - State-derived — host component
watches a reactive condition (isChoreLate,menuOpen,firstPendingChore) and callsmaybeShowwhen it flips true. This is how dynamic kebab options (Extend Timeonly on late chores) and badge-first-appearance (TOO LATE,PENDING) get triggered.
No global DOM scanning, no MutationObserver — every anchor is already owned by a Vue component that knows when it appears.
Step coverage (the JIT hints)
Phase 2 wires these via maybeShow calls in the listed components. All copy is warm/parent-friendly, never jargon.
| Step ID | Trigger | Anchor |
|---|---|---|
setup-parent-pin (intro #1) |
First load after signup, no PIN set | LoginButton.vue avatar |
create-child (intro #2) |
After PIN set, 0 children | FAB in ChildrenListView.vue |
create-chore (intro #3) |
After 1+ child, 0 chores, on Tasks tab | FAB in tasks view |
create-chore-image-photo |
First time ImagePicker opens |
+ button (addPhotoBtn ref) in ImagePicker.vue |
create-chore-image-camera |
First time ImagePicker opens |
Camera button (cameraBtn ref) in ImagePicker.vue |
create-chore-schedule |
First time ScheduleModal opens |
day chips in ScheduleModal.vue |
create-kindness, create-penalty, create-routine |
First time landing on each create view | FAB / form |
create-routine-add-task |
First time inside RoutineEditView |
.add-task-trigger |
create-reward |
First time on Rewards tab with 0 rewards | FAB |
notification-click |
First time on Notifications tab with 1+ notification | first notification row in NotificationView.vue |
select-child then assign-chore / assign-reward / assign-routine |
First time entering ParentView for a specific child | the assignment sections |
child-kebab |
First time .kebab-btn opens in ChildrenListView.vue |
the open menu |
chore-kebab |
First time .kebab-menu opens in ParentView.vue |
the open menu |
chore-kebab-extend-time |
First time the Extend Time menu item renders (chore is late) |
that menu item |
chore-kebab-reset |
First time the Reset menu item renders |
that menu item |
status-too-late |
First time .chore-stamp (TOO LATE) renders for any chore |
the badge |
status-pending |
First time .chore-stamp.pending-stamp renders |
the badge |
Adding a future step = one entry in steps.ts + one maybeShow call at the anchor site.
Profile controls (UserProfile.vue)
Add a new "Help" section after the existing email-digest / push-notifications toggles:
- Show tutorial tips toggle — bound to
tutorialEnabled, copy: "Show helpful tips as I use the app." - Restart tutorial button — opens a
ModalDialogconfirm ("Start the tour again from the beginning?"), then callsresetAllProgress()and routes to/parent(a toast confirms reset).
Dev bypass: support ?tutorial=off / ?tutorial=reset in controller.ts init (gated by import.meta.env.DEV) for debugging without touching the backend.
Child-mode tour (parent learns to teach)
A derived watchEffect in controller.ts:
watchEffect(() => {
const p = tutorialProgress.value
if (p['create-child'] && p['create-chore'] && p['create-reward']
&& !p['child-mode-tour-offer']) {
showOfferModal() // ModalDialog with Yes / Maybe later / No thanks
}
})
- Yes → mark
child-mode-tour-offerseen, navigate to/child, firechild-mode-*steps (separate IDs, same registry/controller). - Maybe later → don't mark seen; reappears once next session, then auto-converts to "No thanks" (max two asks).
- No thanks → mark seen, never asked again (reset clears it).
UX rules baked in (for non-technical users)
- 3-step forced intro, never longer. Single-card JIT hints elsewhere — no chained
Next → Next → Nextoutside the intro. - Skip is always visible, labeled "Skip tour", and one Skip suppresses everything for the session.
- Persistent
?button on every screen — taps re-fire the most relevant step. This is the single most important affordance for users who don't remember the first run. - Copy is short and warm: "Let's add your first kiddo", not "Create a child entity."
- If an anchor element doesn't exist (e.g. 0 chores so no kebab to point at), the step silently waits — never an arrow into empty space.
- Bottom-sheet on mobile keeps text at thumb height and survives the on-screen keyboard via the
visualViewportAPI. prefers-reduced-motion, focus trap, Esc-to-dismiss, ≥5:1 contrast on the highlight ring.
Phasing
Phase 1 — Infrastructure + one end-to-end step.
- Backend
tutorial_enabled/tutorial_progressfields, PATCH endpoint, SSE wiring. - Frontend
controller.ts,steps.ts(one entry),TutorialOverlay.vuemounted inApp.vue, hydrate from profile fetch, listen forprofile_updated. - Ship
create-childend-to-end (triggered fromChildrenListView.vuewhen child list is empty). - Manual verification: sign up a fresh user, see the coach mark, dismiss, confirm it doesn't return; reload to confirm persistence; open in second tab to confirm SSE sync.
Phase 2 — Roll out remaining steps.
- Add the 3-step forced intro chaining (
setup-parent-pin→create-child→create-chore). - Add the JIT step entries from the coverage table; wire
maybeShowcalls at each anchor. - Add
HelpButton.vuein both layouts with arouteToStepmap.
Phase 3 — Profile controls + child-mode tour.
- "Help" section in
UserProfile.vue(toggle + restart button). child-mode-tour-offerwatcher + child-mode step set.- Dev bypass URL params.
Critical files
Backend
backend/models/user.py— model fieldsbackend/api/user_api.py— PATCH endpoint- (no event type changes —
PROFILE_UPDATEDis reused)
Frontend (new)
frontend/src/tutorial/controller.tsfrontend/src/tutorial/steps.tsfrontend/src/tutorial/TutorialOverlay.vuefrontend/src/tutorial/HelpButton.vue
Frontend (modified — maybeShow call sites, mostly one-liners)
frontend/src/App.vue— mountTutorialOverlayfrontend/src/common/models.ts—Userinterface additionsfrontend/src/components/shared/LoginButton.vue— hydrate controller from profile fetchfrontend/src/layout/ParentLayout.vue,frontend/src/layout/ChildLayout.vue—HelpButtonfrontend/src/components/shared/ChildrenListView.vue—create-child,child-kebabfrontend/src/components/child/ParentView.vue—chore-kebab,chore-kebab-extend-time,status-too-late,status-pending,assign-*,select-childfrontend/src/components/task/ChoreEditView.vue+ sibling create views —create-chore,create-kindness,create-penaltyfrontend/src/components/utils/ImagePicker.vue—create-chore-image-photo,create-chore-image-camerafrontend/src/components/routine/RoutineEditView.vue—create-routine,create-routine-add-taskfrontend/src/components/reward/RewardEditView.vue—create-rewardfrontend/src/components/notification/NotificationView.vue—notification-clickfrontend/src/components/shared/ScheduleModal.vue—create-chore-schedulefrontend/src/components/profile/UserProfile.vue— "Help" section
Verification
- Backend:
pytest tests/test_user_api.pycovering the new PATCH branches (mark / reset / disable), plus a check thatPROFILE_UPDATEDis emitted. - Frontend unit:
npm run test:unitforcontroller.ts(shouldShowStep,markStepSeenoptimism, reset/disable). - E2E (
npx playwright test): a new bucketchromium-tutorialwith an isolated user that signs up fresh, walks the 3-step intro, navigates to Rewards/Notifications to hit JIT hints, then resets from the profile and confirms the intro replays. - Manual mobile check: Chrome devtools at 375×667, confirm the bottom-sheet variant renders, survives the on-screen keyboard, and re-positions on rotation.
- Manual cross-device SSE: log in to two tabs, dismiss a step in one, confirm the second tab updates without manual refresh.
- Accessibility: keyboard-only run-through (Tab/Esc/Enter); VoiceOver pass on macOS to confirm
aria-liveannouncements;prefers-reduced-motionset in devtools to confirm pulse animation is suppressed.