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

197 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```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:
```ts
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 entered** — `router.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 `watch`es 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`:
```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-pin` → `create-child` → `create-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.ts` — `User` interface additions
- `frontend/src/components/shared/LoginButton.vue` — hydrate controller from profile fetch
- `frontend/src/layout/ParentLayout.vue`, `frontend/src/layout/ChildLayout.vue` — `HelpButton`
- `frontend/src/components/shared/ChildrenListView.vue` — `create-child`, `child-kebab`
- `frontend/src/components/child/ParentView.vue` — `chore-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.vue` — `create-chore-image-photo`, `create-chore-image-camera`
- `frontend/src/components/routine/RoutineEditView.vue` — `create-routine`, `create-routine-add-task`
- `frontend/src/components/reward/RewardEditView.vue` — `create-reward`
- `frontend/src/components/notification/NotificationView.vue` — `notification-click`
- `frontend/src/components/shared/ScheduleModal.vue` — `create-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.