From 6bf10fda2febacbd691878a5328009bad5901f46 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Tue, 28 Apr 2026 23:30:52 -0400 Subject: [PATCH] feat: implement routines feature for child and parent modes - Added backend models for routines, routine items, and schedules. - Created API endpoints for managing routines and their items. - Implemented frontend components for routine creation, detail view, and assignment. - Integrated routines into child view with a scrolling list and approval workflow. - Added push notifications for routine confirmations and pending approvals. - Refactored existing components to accommodate new routines functionality. - Updated tests to cover new routines feature and ensure proper functionality. --- .github/specs/plan-routinesFeature.prompt.md | 231 ++++++++++++++++++ backend/config/version.py | 2 +- backend/utils/push_sender.py | 7 + frontend/e2e/.auth/user-cc.json | 12 +- frontend/e2e/.auth/user-delete.json | 12 +- frontend/e2e/.auth/user.json | 12 +- .../create-child/happy-path.spec.ts | 10 +- .../create-child/navigation.spec.ts | 10 +- .../e2e/mode_parent/create-child/sse.spec.ts | 10 +- .../create-child/validation.spec.ts | 10 +- .../digest-action-errors.spec.ts | 2 + .../tasks/test-simple-chore.spec.ts | 51 ---- .../user-profile/profile-editing.spec.ts | 37 +-- frontend/e2e/seed.spec.ts | 7 - frontend/src/components/child/ChildView.vue | 4 +- frontend/src/components/child/ParentView.vue | 10 +- .../src/components/shared/LoginButton.vue | 11 + frontend/src/services/pushSubscription.ts | 108 ++++++-- frontend/test-results/.last-run.json | 2 +- 19 files changed, 422 insertions(+), 126 deletions(-) create mode 100644 .github/specs/plan-routinesFeature.prompt.md delete mode 100644 frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts delete mode 100644 frontend/e2e/seed.spec.ts diff --git a/.github/specs/plan-routinesFeature.prompt.md b/.github/specs/plan-routinesFeature.prompt.md new file mode 100644 index 0000000..34c01e8 --- /dev/null +++ b/.github/specs/plan-routinesFeature.prompt.md @@ -0,0 +1,231 @@ +# Plan: Routines Feature + +## Summary + +A "Routines" system lets parents define a named checklist of simple items (e.g., "Morning Routine": Make Bed, Get Dressed, Eat Breakfast) worth X points. Children see routines in a scrollable list between Chores and Rewards in child mode. Tapping a routine opens a detail view showing the item list; a "Done" button submits it for parent approval. Points are awarded on parent approval. Supports scheduling (days/interval), deadlines, time extension, and per-child point overrides — all parallel to the existing chore system. + +--- + +## Key Design Decisions + +- **Routine items** are their own minimal entity (name + optional image, no points). Points belong to the routine. +- **No per-item completion tracking** — detail screen is informational only. "Done" button submits the whole routine. +- **Assignment**: per-child, same pattern as tasks (child.routines: list[str]). +- **Parent approval**: approve/reject the whole routine (no item-level detail). +- **Scheduling**: new RoutineSchedule model parallel to ChoreSchedule (same structure, routine_id instead of task_id). +- **Point overrides**: reuse ChildOverride with entity_type='routine'. +- **Pending confirmations**: extend PendingConfirmation entity_type to include 'routine'. +- **Routine editor**: single-page with details section (name, points, image) + items sub-panel below. + +--- + +## Phase 1: Backend Models & DB + +1. Create `backend/models/routine.py` — fields: name, points, image_id, user_id (inherits BaseModel). Mirror Task model. +2. Create `backend/models/routine_item.py` — fields: routine_id, name, image_id, order (int). No points. +3. Create `backend/models/routine_schedule.py` — copy of ChoreSchedule replacing task_id→routine_id. Same DayConfig, modes, deadline fields. +4. Create `backend/models/routine_extension.py` — fields: child_id, routine_id, date (ISO). Mirror TaskExtension. +5. Extend `backend/models/pending_confirmation.py` — add 'routine' to entity_type Literal. +6. Extend `backend/models/child.py` — add `routines: list[str]` field (default=[]). +7. Create DB layers (mirror existing patterns): + - `backend/db/routines.py` — get/add/update/delete/list by user + - `backend/db/routine_items.py` — get_items_for_routine/add/update/delete/delete_for_routine + - `backend/db/routine_schedules.py` — get/upsert/delete/delete_for_child/delete_for_routine + - `backend/db/routine_extensions.py` — get/add/delete_for_child_routine +8. Extend `backend/db/child_overrides.py` entity_type to allow 'routine'. + +## Phase 2: Backend SSE Events + +9. Create event type files (mirror existing pattern): + - `backend/events/types/routine_modified.py` — payload: routine_id, operation (ADD|EDIT|DELETE) + - `backend/events/types/child_routines_set.py` — payload: child_id, routine_ids + - `backend/events/types/routine_schedule_modified.py` — payload: child_id, routine_id, operation (SET|DELETED) + - `backend/events/types/routine_time_extended.py` — payload: child_id, routine_id + - `backend/events/types/child_routine_confirmation.py` — payload: child_id, routine_id, operation (PENDING|APPROVED|REJECTED|RESET) +10. Update `backend/events/types/event_types.py` — add: ROUTINE_MODIFIED, CHILD_ROUTINES_SET, ROUTINE_SCHEDULE_MODIFIED, ROUTINE_TIME_EXTENDED, CHILD_ROUTINE_CONFIRMATION + +## Phase 3: Backend API + +11. Create `backend/api/routine_api.py`: + - `PUT /routine/add` — create routine + - `GET /routine/` — fetch single + - `GET /routine/list` — list by user + - `PUT /routine//edit` — update + - `DELETE /routine/` — delete (cascade: remove from all children, delete items, schedules, overrides) +12. Create `backend/api/routine_item_api.py`: + - `PUT /routine//item/add` — add item + - `PUT /routine//item//edit` — edit item name/image + - `DELETE /routine//item/` — remove item + - `GET /routine//items` — list items +13. Create `backend/api/child_routine_api.py`: + - `POST /child//assign-routine` — add routine to child + - `POST /child//remove-routine` — remove from child + - `PUT /child//set-routines` — replace all assigned routines + - `GET /child//list-routines` — list assigned routines with schedule, pending_status, extension_date, image_url, custom_value, and embedded items + - `GET /child//list-assignable-routines` — routines not yet assigned + - `POST /child//confirm-routine` — child submits routine as pending (creates PendingConfirmation entity_type='routine') + - `POST /child//cancel-routine-confirmation` — cancel pending +14. Create `backend/api/routine_schedule_api.py`: + - `GET /child//routine//schedule` — fetch + - `PUT /child//routine//schedule` — create/update + - `DELETE /child//routine//schedule` — delete + - `POST /child//routine//extend` — extend deadline +15. Extend `backend/api/pending_confirmation.py`: + - Add `GET /pending-confirmations?type=routine` support (or ensure existing endpoint includes routines) + - Add `POST /child//approve-routine/` — approve (award points via override or routine.points) + - Add `POST /child//reject-routine/` — reject + - Add `POST /child//reset-routine/` — reset +16. Update `backend/api/child_api.py` — cascade delete routines/schedules/extensions when child deleted. +17. Update `backend/main.py` — register all new blueprints. + +## Phase 4: TypeScript Models & API Helpers + +18. Update `frontend/src/common/models.ts`: + - Add `Routine` (id, name, points, image_id) + - Add `RoutineItem` (id, routine_id, name, image_id, order) + - Add `ChildRoutine` (extends Routine + custom_value, schedule, extension_date, pending_status, approved_at, items: RoutineItem[]) + - Add `RoutineSchedule` (mirror ChoreSchedule with routine_id) + - Add new SSE payload types: RoutineModifiedEventPayload, ChildRoutinesSetEventPayload, RoutineScheduleModifiedPayload, RoutineTimeExtendedPayload, ChildRoutineConfirmationPayload +19. Update `frontend/src/common/backendEvents.ts` — add new event type string constants. +20. Update `frontend/src/common/api.ts` — add helpers: confirmRoutine(), cancelRoutineConfirmation(), setChildRoutineOverride(). + +## Phase 5: Frontend — Child Mode + +21. Create `frontend/src/components/routine/RoutineDetailView.vue`: + - Receives childId + routineId as route params + - Fetches routine data (name, points, image, items list) via `GET /child//list-routines` (or dedicated detail endpoint) + - Displays routine header (name, image, points) + - Vertical card list of items (name + image, non-interactive) + - "Done" button → RoutineConfirmDialog → calls confirmRoutine() → routine becomes 'pending' + - If pending_status='pending': "Done" shows cancel dialog instead + - If approved today: shows "COMPLETED" stamp (read-only) + - If expired: shows "TOO LATE" (no action) + - Back navigation to ChildView + - Register SSE `child_routine_confirmation` to update status reactively +22. Update `frontend/src/components/child/ChildView.vue`: + - Add `routines: ref` and `childRoutineListRef` + - Add Routines `ScrollingList` between Chores list and Rewards list + - fetchBaseUrl: `/api/child/${child.id}/list-routines` + - itemKey: `'routines'` + - filter-fn: filter by schedule (isScheduledToday equivalent for routines) + - sort-fn: completed → pending → scheduled → general + - getItemClass: similar chore-inactive logic for expired/completed + - On routine click → navigate to RoutineDetailView route instead of triggering inline + - Register new SSE handlers: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation + - Expiry timer logic for routine deadlines (parallel to existing chore timer logic) +23. Add routes: + - `/child/:id/routine/:routineId` → RoutineDetailView + +## Phase 6: Frontend — Parent Mode (Routine Library) + +24. Create `frontend/src/components/routine/RoutineEditor.vue`: + - Top section: EntityEditForm-style fields — name (text), points (number), image picker + - Bottom section: Items sub-panel + - List of existing items (name + image thumbnail + delete button) + - "Add item" row: inline input for name + optional image picker + confirm button + - Each item has edit-in-place or edit button + - Save button submits both routine details and item changes + - On edit mode: pre-populate all fields and items +25. Create `frontend/src/views/parent/RoutinesView.vue`: + - ItemList of all routines with add/edit/delete + - Add → RoutineEditor (create mode) + - Edit → RoutineEditor (edit mode) +26. Add parent routes under TaskSubNav (4th tab — "Routines"): + - `/parent/tasks/routines` — library list (RoutinesView) + - `/parent/tasks/routines/add` — create (RoutineEditor) + - `/parent/tasks/routines/:id/edit` — edit (RoutineEditor) +27. Add tab to `frontend/src/components/task/TaskSubNav.vue` — "Routines" tab pointing to `/parent/tasks/routines` + +## Phase 7: Frontend — Parent Mode (Per-Child Routine Management) + +28. Extend ParentView (or child management component) to include a "Routines" section per child: + - ItemList showing child's assigned routines + - Assign button → RoutineAssignView (parallel to ChoreAssignView) + - Kebab menu per routine item: + - "Edit Routine" → navigate to `/parent/tasks/routines/:id/edit` + - "Set Schedule" → open ScheduleModal with entityType='routine' + - "Edit Points" → set ChildOverride with entity_type='routine' + - "Extend Deadline" → call extend endpoint + - Register SSE events to refresh list: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation, child_override_set +29. Create `frontend/src/components/routine/RoutineAssignView.vue` — selectable ItemList of unassigned routines (parallel to ChoreAssignView). +30. Extend pending confirmation approval UI — if entity_type='routine', fetch routine name and show in approval card. Approve → POST approve-routine endpoint. Reject → POST reject-routine. + +## Phase 8: Push Notifications for Routine Pending + +31. In `backend/api/child_routine_api.py` — after creating the PendingConfirmation for a routine, call `send_push_to_user(user_id, payload)` (mirror `child_api.py` chore confirmation pattern). Payload: `type='routine_confirmed'`, title = "Routine Pending", body = "{child_name} completed {routine_name}", include approve/reject action tokens. + +## Phase 9: ScheduleModal entityType Refactor + +32. Refactor `ScheduleModal.vue` — replace hard-coded `task_id` with `entityType: 'task' | 'routine'` + `entityId` props. All API calls to `.../schedule` and `.../extend` switch on `entityType` to route to either `/child//task//...` or `/child//routine//...`. +33. Update all current ScheduleModal usages to explicitly pass `entityType='task'` so existing behavior is preserved. +34. Routine kebab "Set Schedule" passes `entityType='routine'`. + +--- + +## Relevant Files + +**New backend:** + +- `backend/models/routine.py`, `routine_item.py`, `routine_schedule.py`, `routine_extension.py` +- `backend/db/routines.py`, `routine_items.py`, `routine_schedules.py`, `routine_extensions.py` +- `backend/api/routine_api.py`, `routine_item_api.py`, `child_routine_api.py`, `routine_schedule_api.py` +- `backend/events/types/routine_modified.py`, `child_routines_set.py`, `routine_schedule_modified.py`, `routine_time_extended.py`, `child_routine_confirmation.py` + +**Modified backend:** + +- `backend/models/child.py` — add routines field +- `backend/models/pending_confirmation.py` — extend entity_type Literal +- `backend/db/child_overrides.py` — allow 'routine' entity_type +- `backend/api/child_api.py` — cascade deletes +- `backend/api/pending_confirmation.py` — routine approval endpoints +- `backend/events/types/event_types.py` — new constants +- `backend/main.py` — register blueprints + +**New frontend:** + +- `frontend/src/components/routine/RoutineEditor.vue` +- `frontend/src/components/routine/RoutineDetailView.vue` +- `frontend/src/components/routine/RoutineAssignView.vue` +- `frontend/src/views/parent/RoutinesView.vue` + +**Modified frontend:** + +- `frontend/src/common/models.ts` — new interfaces + SSE payload types +- `frontend/src/common/backendEvents.ts` — new event constants +- `frontend/src/common/api.ts` — new helpers +- `frontend/src/components/child/ChildView.vue` — add routines list + navigation +- `frontend/src/components/task/TaskSubNav.vue` — add Routines tab +- `frontend/src/components/shared/ScheduleModal.vue` — entityType refactor +- ParentView component — add routines section +- Router — new routes + +**Reference patterns:** + +- `backend/api/chore_api.py` + `chore_schedule_api.py` — mirror for routine equivalents +- `backend/models/chore_schedule.py` — copy for RoutineSchedule +- `backend/models/task_extension.py` — copy for routine_extension.py +- `frontend/src/components/shared/ScrollingList.vue` — reuse for routines in child view +- `frontend/src/components/shared/EntityEditForm.vue` — reuse in RoutineEditor top section +- `backend/utils/push_sender.py` + `child_api.py` chore confirm (~L1186) — push notification pattern + +--- + +## Verification + +1. Create a routine with 2+ items via Tasks → Routines tab, assign to a child, verify it appears in child view +2. Child submits "Done" → push notification fires → pending confirmation appears in parent view → approve → points credited +3. Reject flow → no points awarded +4. Schedule a routine for specific days → verify it only appears on those days +5. Set a deadline → verify "TOO LATE" stamp after deadline passes +6. Extend deadline via kebab → verify stamp removed for that day +7. Set per-child point override → verify override value shown and applied on approval +8. Delete routine → cascades (removed from children, items/schedules/extensions/overrides deleted) +9. SSE: parent sees routine confirmation in notifications without page refresh +10. ScheduleModal: existing chore schedule still works after entityType refactor + +--- + +## Out of Scope + +- Reordering routine items (order field exists but no drag-and-drop UI planned) +- Per-item completion tracking diff --git a/backend/config/version.py b/backend/config/version.py index 9be0512..8cab30d 100644 --- a/backend/config/version.py +++ b/backend/config/version.py @@ -2,7 +2,7 @@ # file: config/version.py import os -BASE_VERSION = "1.0.10" # update manually when releasing features +BASE_VERSION = "1.0.11" # update manually when releasing features def get_full_version() -> str: """ diff --git a/backend/utils/push_sender.py b/backend/utils/push_sender.py index 23bc375..1c2d4a1 100644 --- a/backend/utils/push_sender.py +++ b/backend/utils/push_sender.py @@ -58,6 +58,13 @@ def send_push_to_user(user_id: str, payload: dict) -> int: f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})' ) delete_subscription_by_id(sub.id) + elif status_code == 403 and 'VAPID credentials' in str(e): + # The subscription was created with a different VAPID keypair. + # Remove it so the client can register a fresh subscription next time. + logger.info( + f'Removing VAPID-mismatched subscription {sub.id} for user {user_id} (HTTP 403)' + ) + delete_subscription_by_id(sub.id) else: logger.error( f'WebPushException for subscription {sub.id} (user {user_id}): {e}' diff --git a/frontend/e2e/.auth/user-cc.json b/frontend/e2e/.auth/user-cc.json index 6d184a8..944137f 100644 --- a/frontend/e2e/.auth/user-cc.json +++ b/frontend/e2e/.auth/user-cc.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "v-K0IrmQf50MVvYCdmQfnUor8Q8e9KURnPnyVPYRN8Q", + "value": "1aCKS1DujEPmHYGCELY7JiJ-LfxGjw7_HK1uCymlbh8", "domain": "localhost", "path": "/api/auth", - "expires": 1785003958.597918, + "expires": 1785207783.273047, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0NDg5ZjZhNS1mZWEyLTQ0ZGEtYTczNy00MDQwNTQyMmU0YTEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcyMjg4NTh9.V7gsUwZ-mjcRqCIwI2ppFVrGD16R18JJDQ5wsv_ul1k", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzY5YzY4Zi04MWQ5LTRiNjctODQwYS1mY2Q5NTVkNzAwNzYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODN9.vdmfmIx0Dg4zKq9HTV7N7tjDbSGaO3EOeGqJbhFSOcQ", "domain": "localhost", "path": "/", - "expires": 1777228858.597862, + "expires": 1777442583.273, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1777227958453}" + "value": "{\"type\":\"logout\",\"at\":1777431783134}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1777400758775}" + "value": "{\"expiresAt\":1777604583443}" } ] } diff --git a/frontend/e2e/.auth/user-delete.json b/frontend/e2e/.auth/user-delete.json index 7b36770..c609056 100644 --- a/frontend/e2e/.auth/user-delete.json +++ b/frontend/e2e/.auth/user-delete.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "s8cSiajWyuCcjv9zXjqsKrmEK8cRPp-VOWV74Cz_ueQ", + "value": "eDlr3-DaFNFoWQE3EDAybLFpw7TiR3Maj05kLIkZfu0", "domain": "localhost", "path": "/api/auth", - "expires": 1785003958.599099, + "expires": 1785207783.382249, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiYmE3MDNmMWItMzg0Zi00MzBjLWJiNjYtN2Q0YWRhMWIwYjRiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3MjI4ODU4fQ.FnEEIVv2wchkrJhDiRuNiDBF-lIPkyZUU2UwLmLZVHM", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNGYwNmY0YTMtMGQwMC00NzJhLWJkMjQtYjM1YjdkMjg4ZDFiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NDQyNTgzfQ.F2TXov3xbhFa20P5NLt6J5HfNDlY2aXlnPoSYYDJpoQ", "domain": "localhost", "path": "/", - "expires": 1777228858.599061, + "expires": 1777442583.38218, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1777227958453}" + "value": "{\"type\":\"logout\",\"at\":1777431783225}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1777400758775}" + "value": "{\"expiresAt\":1777604583575}" } ] } diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json index 3e2b5ab..e9fcdcd 100644 --- a/frontend/e2e/.auth/user.json +++ b/frontend/e2e/.auth/user.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "mqtPPFKJGGfFly1i0VW1MtJUvgIpb7LlWUEpFLn1hGc", + "value": "BGnvmJr7XNXaCsXlzh1k6RcJPIGVxac6_LBXS22gXIA", "domain": "localhost", "path": "/api/auth", - "expires": 1785022348.96348, + "expires": 1785207781.614965, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzZDJmZmY0OS03MjNhLTRhMmUtYWRhOS1iMTE5N2VhYTI2MDkiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcyNTcxNDh9.g69Lwtay2i0jwEzAbYcRWM2oGoOm-4qpV8YC04X0v-0", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YmQxMWY5NC1jNWJhLTRhNmEtOGZhMi1hZjZkYzk3Y2YyMjgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODF9.WVDBt-zfwVMHCWr78maSGWAyfQZhWOSobX5oXKXLHwg", "domain": "localhost", "path": "/", - "expires": 1777257148.963437, + "expires": 1777442581.61492, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1777246348801}" + "value": "{\"type\":\"logout\",\"at\":1777431781473}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1777419149107}" + "value": "{\"expiresAt\":1777604581743}" } ] } diff --git a/frontend/e2e/mode_parent/create-child/happy-path.spec.ts b/frontend/e2e/mode_parent/create-child/happy-path.spec.ts index 53edc37..8736d89 100644 --- a/frontend/e2e/mode_parent/create-child/happy-path.spec.ts +++ b/frontend/e2e/mode_parent/create-child/happy-path.spec.ts @@ -7,6 +7,12 @@ import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png') +/** Navigate to the parent children list and wait for the Add Child button to be ready. */ +async function gotoParentList(page: import('@playwright/test').Page): Promise { + await page.goto('/') + await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 }) +} + async function deleteNamedChildren(request: any, names: string[]) { const res = await request.get('/api/child/list') const data = await res.json() @@ -38,7 +44,7 @@ test.describe('Create Child', () => { await deleteNamedChildren(request, ['Alice']) // 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated - await page.goto('/') + await gotoParentList(page) await expect(page).toHaveURL('/parent') // 2. Click the 'Add Child' FAB @@ -123,7 +129,7 @@ test.describe('Create Child', () => { await deleteNamedChildren(request, ['Grace']) // 1. Navigate to app root - router redirects to /parent (children list) - await page.goto('/') + await gotoParentList(page) await page.getByRole('button', { name: 'Add Child' }).click() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() diff --git a/frontend/e2e/mode_parent/create-child/navigation.spec.ts b/frontend/e2e/mode_parent/create-child/navigation.spec.ts index 775a276..3310fb4 100644 --- a/frontend/e2e/mode_parent/create-child/navigation.spec.ts +++ b/frontend/e2e/mode_parent/create-child/navigation.spec.ts @@ -2,10 +2,16 @@ import { test, expect } from '@playwright/test' +/** Navigate to the parent children list and wait for the Add Child button to be ready. */ +async function gotoParentList(page: import('@playwright/test').Page): Promise { + await page.goto('/') + await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 }) +} + test.describe('Create Child', () => { test('Cancel navigates back without saving', async ({ page }) => { - // 1. Navigate to app root - router redirects to /parent (children list) - await page.goto('/') + // 1. Navigate to parent list and wait for it to fully load + await gotoParentList(page) await page.getByRole('button', { name: 'Add Child' }).click() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() diff --git a/frontend/e2e/mode_parent/create-child/sse.spec.ts b/frontend/e2e/mode_parent/create-child/sse.spec.ts index 50733f3..6719aaf 100644 --- a/frontend/e2e/mode_parent/create-child/sse.spec.ts +++ b/frontend/e2e/mode_parent/create-child/sse.spec.ts @@ -3,6 +3,12 @@ import { test, expect } from '@playwright/test' import { STORAGE_STATE_CC } from '../../e2e-constants' +/** Navigate to the parent children list and wait for the Add Child button to be ready. */ +async function gotoParentList(page: import('@playwright/test').Page): Promise { + await page.goto('/') + await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 }) +} + test.describe('Create Child', () => { test.describe.configure({ mode: 'serial' }) @@ -17,7 +23,7 @@ test.describe('Create Child', () => { } // 1. Open two browser tabs both on /parent (children list) - await page.goto('/') + await gotoParentList(page) await expect(page).toHaveURL('/parent') const tab2 = await context.newPage() @@ -64,7 +70,7 @@ test.describe('Create Child', () => { } // 1. Tab 1: parent mode - await page.goto('/') + await gotoParentList(page) await expect(page).toHaveURL('/parent') // 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1 diff --git a/frontend/e2e/mode_parent/create-child/validation.spec.ts b/frontend/e2e/mode_parent/create-child/validation.spec.ts index 9e3d237..974b39f 100644 --- a/frontend/e2e/mode_parent/create-child/validation.spec.ts +++ b/frontend/e2e/mode_parent/create-child/validation.spec.ts @@ -2,12 +2,18 @@ import { test, expect } from '@playwright/test' +/** Navigate to the parent children list and wait for the Add Child button to be ready. */ +async function gotoParentList(page: import('@playwright/test').Page): Promise { + await page.goto('/') + await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 }) +} + test.describe('Create Child', () => { test.beforeEach(async ({ page }, testInfo) => { test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode') - // Navigate to app root - router redirects to /parent (children list) when parent-authenticated - await page.goto('/') + // Navigate to parent list and wait for it to fully load before clicking Add Child + await gotoParentList(page) await page.getByRole('button', { name: 'Add Child' }).click() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() }) diff --git a/frontend/e2e/mode_parent/notifications/digest-action-errors.spec.ts b/frontend/e2e/mode_parent/notifications/digest-action-errors.spec.ts index 6a9e844..98ab1fa 100644 --- a/frontend/e2e/mode_parent/notifications/digest-action-errors.spec.ts +++ b/frontend/e2e/mode_parent/notifications/digest-action-errors.spec.ts @@ -39,6 +39,8 @@ async function getUnauthContext(playwright: any) { } test.describe('Digest Action Token — Error Paths', () => { + test.describe.configure({ mode: 'serial' }) + let childId = '' let choreId = '' diff --git a/frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts b/frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts deleted file mode 100644 index cd59fb0..0000000 --- a/frontend/e2e/mode_parent/tasks/test-simple-chore.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { test, expect } from '@playwright/test' - -test('Simple chore creation test', async ({ page }) => { - // Navigate to chores - await page.goto('/parent/tasks/chores') - - // Click the Create Chore FAB - await page.getByRole('button', { name: 'Create Chore' }).click() - - // Wait for form to load - await expect(page.locator('text=Chore Name')).toBeVisible() - - // Fill form using custom evaluation - await page.evaluate(() => { - // Fill name - const nameInput = document.querySelector('input[type="text"], input:not([type])') - if (nameInput) { - nameInput.value = 'Simple Chore Test' - nameInput.dispatchEvent(new Event('input', { bubbles: true })) - nameInput.dispatchEvent(new Event('change', { bubbles: true })) - } - - // Fill points - const pointsInput = document.querySelector('input[type="number"]') - if (pointsInput) { - pointsInput.value = '5' - pointsInput.dispatchEvent(new Event('input', { bubbles: true })) - pointsInput.dispatchEvent(new Event('change', { bubbles: true })) - } - - // Click first image to select it - const firstImage = document.querySelector('img[alt*="Image"]') - if (firstImage) { - firstImage.click() - } - }) - - // Wait a moment for validation to trigger - await page.waitForTimeout(500) - - // Click Create button - await page.getByRole('button', { name: 'Create' }).click() - - // Verify we're back on the list page and item was created - // locate the name element, then move up to the row container - const choreName = page.locator('text=Simple Chore Test').first() - const choreRow = choreName.locator('..') - await expect(choreRow).toBeVisible() - // the row container should display the correct points value - await expect(choreRow.locator('text=5 pts')).toBeVisible() -}) diff --git a/frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts b/frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts index 8a738c8..177a353 100644 --- a/frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts +++ b/frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts @@ -32,6 +32,13 @@ async function restoreProfile(request: APIRequestContext, profile: ProfileData): }) } +/** Navigate to /parent/profile and wait for the form to finish loading. */ +async function gotoProfile(page: import('@playwright/test').Page): Promise { + await page.goto('/parent/profile') + // EntityEditForm hides the form behind v-if while loading=true; wait for it to render. + await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 }) +} + test.describe('User Profile – editing', () => { test.describe.configure({ mode: 'serial' }) @@ -46,7 +53,7 @@ test.describe('User Profile – editing', () => { }) test('Profile page loads with correct data', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible() await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME) @@ -68,13 +75,13 @@ test.describe('User Profile – editing', () => { }) test('Save is disabled when form is clean (not dirty)', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() }) test('Save is disabled when First Name is empty', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByLabel('First Name').fill('') await page.getByLabel('First Name').blur() @@ -83,7 +90,7 @@ test.describe('User Profile – editing', () => { }) test('Save is disabled when Last Name is empty', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByLabel('Last Name').fill('') await page.getByLabel('Last Name').blur() @@ -92,7 +99,7 @@ test.describe('User Profile – editing', () => { }) test('Save is disabled when both name fields are empty', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByLabel('First Name').fill('') await page.getByLabel('Last Name').fill('') @@ -101,7 +108,7 @@ test.describe('User Profile – editing', () => { }) test('Save enables when a name is changed', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByLabel('First Name').fill('UpdatedE2E') @@ -109,7 +116,7 @@ test.describe('User Profile – editing', () => { }) test('Save persists name changes and shows confirmation modal', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByLabel('First Name').fill('UpdatedE2E') await page.getByLabel('Last Name').fill('UpdatedTester') @@ -121,25 +128,25 @@ test.describe('User Profile – editing', () => { await dialog.getByRole('button', { name: 'OK' }).click() // OK navigates back; go back to profile to verify persistence - await page.goto('/parent/profile') + await gotoProfile(page) await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E') await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester') }) test('Cancel discards unsaved changes', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByLabel('First Name').fill('Discarded') await page.getByRole('button', { name: 'Cancel' }).click() // Navigate back to verify no changes were saved - await page.goto('/parent/profile') + await gotoProfile(page) await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME) }) test('Email field is read-only', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) const emailInput = page.locator('#email') await expect(emailInput).toBeDisabled() @@ -147,7 +154,7 @@ test.describe('User Profile – editing', () => { }) test('Change profile image (built-in)', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) // Wait for images to load await page.waitForSelector('.selectable-image') @@ -181,7 +188,7 @@ test.describe('User Profile – editing', () => { await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click() // Re-visit and confirm selection persists - await page.goto('/parent/profile') + await gotoProfile(page) await page.waitForSelector('.selectable-image') const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src') expect(selectedSrc).toBeTruthy() @@ -191,7 +198,7 @@ test.describe('User Profile – editing', () => { }) test('Upload a custom profile image', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.waitForSelector('.selectable-image') @@ -214,7 +221,7 @@ test.describe('User Profile – editing', () => { }) test('Change Password shows email-sent modal', async ({ page }) => { - await page.goto('/parent/profile') + await gotoProfile(page) await page.getByRole('button', { name: 'Change Password' }).click() diff --git a/frontend/e2e/seed.spec.ts b/frontend/e2e/seed.spec.ts deleted file mode 100644 index ef5ce4c..0000000 --- a/frontend/e2e/seed.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Test group', () => { - test('seed', async ({ page }) => { - // generate code here. - }); -}); diff --git a/frontend/src/components/child/ChildView.vue b/frontend/src/components/child/ChildView.vue index 25c65df..9541e27 100644 --- a/frontend/src/components/child/ChildView.vue +++ b/frontend/src/components/child/ChildView.vue @@ -622,10 +622,10 @@ onUnmounted(() => { :sort-fn="childChoreSort" >