Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
- 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.
197 lines
13 KiB
Markdown
197 lines
13 KiB
Markdown
# 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.
|