Files
chore/plan-tutorial.md
Ryan Kegel e2bb9cd6b9
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
feat: add onboarding tutorial for new users
- 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.
2026-06-19 17:27:35 -04:00

13 KiB
Raw Blame History

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] and tutorial_enabled: bool on the User model, broadcast via existing PROFILE_UPDATED SSE event.
  • Step authoring: imperative tutorial.maybeShow('step-id', anchorRef) from onMounted / watch in each host component, with step copy in a single registry file.

Architecture

Backend

  • backend/models/user.py — add tutorial_enabled: bool = True and tutorial_progress: dict = field(default_factory=dict) alongside email_digest_enabled. Update from_dict / to_dict.
  • backend/api/user_api.py — add PATCH /user/tutorial-progress that accepts:
    • { "step_id": "...", "seen": true } — mark one step
    • { "reset": true } — clear the dict
    • { "enabled": false } — toggle Each branch emits EventType.PROFILE_UPDATED (already wired) so other tabs / devices stay in sync.

Frontend — new directory frontend/src/tutorial/

  • controller.ts — module-level ref()s (matches the no-Pinia pattern from stores/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 in LoginButton.vue); re-hydrated on profile_updated SSE.

  • 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 in App.vue. Watches activeStep. 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 (no floating-ui — same hand-rolled pattern as the existing TimePickerPopover.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-phase scroll, and ResizeObserver on the card.
    • role="dialog", focus trap, Esc to dismiss, aria-live="polite" for announcements, prefers-reduced-motion kills animations.
  • HelpButton.vue — a small ? icon placed in the ParentLayout topbar (and ChildLayout). On tap, re-fires the most relevant step for the current route from a routeToStep map. This is the user's escape hatch — critical for the non-technical audience.

Triggering — three sources, all routed through maybeShow

  1. Route enteredrouter.afterEach in controller.ts consults a small routeTriggers: Record<RouteName, string> map (used for tab-tasks, tab-rewards, tab-notifications, …).
  2. Element first rendered — host component calls maybeShow in onMounted or watchEffect once its anchor ref is bound.
  3. State-derived — host component watches a reactive condition (isChoreLate, menuOpen, firstPendingChore) and calls maybeShow when it flips true. This is how dynamic kebab options (Extend Time only 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 ModalDialog confirm ("Start the tour again from the beginning?"), then calls resetAllProgress() 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-offer seen, navigate to /child, fire child-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 → Next outside 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 visualViewport API.
  • 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_progress fields, PATCH endpoint, SSE wiring.
  • Frontend controller.ts, steps.ts (one entry), TutorialOverlay.vue mounted in App.vue, hydrate from profile fetch, listen for profile_updated.
  • Ship create-child end-to-end (triggered from ChildrenListView.vue when 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-pincreate-childcreate-chore).
  • Add the JIT step entries from the coverage table; wire maybeShow calls at each anchor.
  • Add HelpButton.vue in both layouts with a routeToStep map.

Phase 3 — Profile controls + child-mode tour.

  • "Help" section in UserProfile.vue (toggle + restart button).
  • child-mode-tour-offer watcher + child-mode step set.
  • Dev bypass URL params.

Critical files

Backend

  • backend/models/user.py — model fields
  • backend/api/user_api.py — PATCH endpoint
  • (no event type changes — PROFILE_UPDATED is reused)

Frontend (new)

  • frontend/src/tutorial/controller.ts
  • frontend/src/tutorial/steps.ts
  • frontend/src/tutorial/TutorialOverlay.vue
  • frontend/src/tutorial/HelpButton.vue

Frontend (modified — maybeShow call sites, mostly one-liners)

  • frontend/src/App.vue — mount TutorialOverlay
  • frontend/src/common/models.tsUser interface additions
  • frontend/src/components/shared/LoginButton.vue — hydrate controller from profile fetch
  • frontend/src/layout/ParentLayout.vue, frontend/src/layout/ChildLayout.vueHelpButton
  • frontend/src/components/shared/ChildrenListView.vuecreate-child, child-kebab
  • frontend/src/components/child/ParentView.vuechore-kebab, chore-kebab-extend-time, status-too-late, status-pending, assign-*, select-child
  • frontend/src/components/task/ChoreEditView.vue + sibling create views — create-chore, create-kindness, create-penalty
  • frontend/src/components/utils/ImagePicker.vuecreate-chore-image-photo, create-chore-image-camera
  • frontend/src/components/routine/RoutineEditView.vuecreate-routine, create-routine-add-task
  • frontend/src/components/reward/RewardEditView.vuecreate-reward
  • frontend/src/components/notification/NotificationView.vuenotification-click
  • frontend/src/components/shared/ScheduleModal.vuecreate-chore-schedule
  • frontend/src/components/profile/UserProfile.vue — "Help" section

Verification

  • Backend: pytest tests/test_user_api.py covering the new PATCH branches (mark / reset / disable), plus a check that PROFILE_UPDATED is emitted.
  • Frontend unit: npm run test:unit for controller.ts (shouldShowStep, markStepSeen optimism, reset/disable).
  • E2E (npx playwright test): a new bucket chromium-tutorial with 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-live announcements; prefers-reduced-motion set in devtools to confirm pulse animation is suppressed.