2 Commits

Author SHA1 Message Date
6bf10fda2f feat: implement routines feature for child and parent modes
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m23s
- 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.
2026-04-28 23:30:52 -04:00
c840825549 feat: add workflow for promoting master to production with testing and deployment steps
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 23:30:42 -04:00
20 changed files with 447 additions and 126 deletions

View File

@@ -476,3 +476,28 @@ jobs:
- tests: ${{ needs.tests.result }} - tests: ${{ needs.tests.result }}
- deploy: ${{ needs.deploy.result }} - deploy: ${{ needs.deploy.result }}
- tag: ${{ needs.create-release-tag.result }} - tag: ${{ needs.create-release-tag.result }}
- name: Send email when promotion succeeds
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
convert_markdown: true
html_body: |
### Production promotion succeeded
- Repository: ${{ gitea.repository }}
- Ref: ${{ needs.prepare.outputs.target_ref }}
- Commit: ${{ needs.prepare.outputs.commit_sha }}
- Release tag: ${{ needs.prepare.outputs.release_tag }}
- prepare: ${{ needs.prepare.result }}
- tests: ${{ needs.tests.result }}
- deploy: ${{ needs.deploy.result }}
- tag: ${{ needs.create-release-tag.result }}

View File

@@ -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/<id>` — fetch single
- `GET /routine/list` — list by user
- `PUT /routine/<id>/edit` — update
- `DELETE /routine/<id>` — delete (cascade: remove from all children, delete items, schedules, overrides)
12. Create `backend/api/routine_item_api.py`:
- `PUT /routine/<routine_id>/item/add` — add item
- `PUT /routine/<routine_id>/item/<item_id>/edit` — edit item name/image
- `DELETE /routine/<routine_id>/item/<item_id>` — remove item
- `GET /routine/<routine_id>/items` — list items
13. Create `backend/api/child_routine_api.py`:
- `POST /child/<id>/assign-routine` — add routine to child
- `POST /child/<id>/remove-routine` — remove from child
- `PUT /child/<id>/set-routines` — replace all assigned routines
- `GET /child/<id>/list-routines` — list assigned routines with schedule, pending_status, extension_date, image_url, custom_value, and embedded items
- `GET /child/<id>/list-assignable-routines` — routines not yet assigned
- `POST /child/<id>/confirm-routine` — child submits routine as pending (creates PendingConfirmation entity_type='routine')
- `POST /child/<id>/cancel-routine-confirmation` — cancel pending
14. Create `backend/api/routine_schedule_api.py`:
- `GET /child/<child_id>/routine/<routine_id>/schedule` — fetch
- `PUT /child/<child_id>/routine/<routine_id>/schedule` — create/update
- `DELETE /child/<child_id>/routine/<routine_id>/schedule` — delete
- `POST /child/<child_id>/routine/<routine_id>/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/<id>/approve-routine/<confirmation_id>` — approve (award points via override or routine.points)
- Add `POST /child/<id>/reject-routine/<confirmation_id>` — reject
- Add `POST /child/<id>/reset-routine/<confirmation_id>` — 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/<id>/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<string[]>` 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/<childId>/task/<entityId>/...` or `/child/<childId>/routine/<entityId>/...`.
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

View File

@@ -2,7 +2,7 @@
# file: config/version.py # file: config/version.py
import os 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: def get_full_version() -> str:
""" """

View File

@@ -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})' f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})'
) )
delete_subscription_by_id(sub.id) 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: else:
logger.error( logger.error(
f'WebPushException for subscription {sub.id} (user {user_id}): {e}' f'WebPushException for subscription {sub.id} (user {user_id}): {e}'

View File

@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "v-K0IrmQf50MVvYCdmQfnUor8Q8e9KURnPnyVPYRN8Q", "value": "1aCKS1DujEPmHYGCELY7JiJ-LfxGjw7_HK1uCymlbh8",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1785003958.597918, "expires": 1785207783.273047,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0NDg5ZjZhNS1mZWEyLTQ0ZGEtYTczNy00MDQwNTQyMmU0YTEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcyMjg4NTh9.V7gsUwZ-mjcRqCIwI2ppFVrGD16R18JJDQ5wsv_ul1k", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzY5YzY4Zi04MWQ5LTRiNjctODQwYS1mY2Q5NTVkNzAwNzYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODN9.vdmfmIx0Dg4zKq9HTV7N7tjDbSGaO3EOeGqJbhFSOcQ",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1777228858.597862, "expires": 1777442583.273,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777227958453}" "value": "{\"type\":\"logout\",\"at\":1777431783134}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1777400758775}" "value": "{\"expiresAt\":1777604583443}"
} }
] ]
} }

View File

@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "s8cSiajWyuCcjv9zXjqsKrmEK8cRPp-VOWV74Cz_ueQ", "value": "eDlr3-DaFNFoWQE3EDAybLFpw7TiR3Maj05kLIkZfu0",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1785003958.599099, "expires": 1785207783.382249,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiYmE3MDNmMWItMzg0Zi00MzBjLWJiNjYtN2Q0YWRhMWIwYjRiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3MjI4ODU4fQ.FnEEIVv2wchkrJhDiRuNiDBF-lIPkyZUU2UwLmLZVHM", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNGYwNmY0YTMtMGQwMC00NzJhLWJkMjQtYjM1YjdkMjg4ZDFiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NDQyNTgzfQ.F2TXov3xbhFa20P5NLt6J5HfNDlY2aXlnPoSYYDJpoQ",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1777228858.599061, "expires": 1777442583.38218,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777227958453}" "value": "{\"type\":\"logout\",\"at\":1777431783225}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1777400758775}" "value": "{\"expiresAt\":1777604583575}"
} }
] ]
} }

View File

@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "mqtPPFKJGGfFly1i0VW1MtJUvgIpb7LlWUEpFLn1hGc", "value": "BGnvmJr7XNXaCsXlzh1k6RcJPIGVxac6_LBXS22gXIA",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1785022348.96348, "expires": 1785207781.614965,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzZDJmZmY0OS03MjNhLTRhMmUtYWRhOS1iMTE5N2VhYTI2MDkiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcyNTcxNDh9.g69Lwtay2i0jwEzAbYcRWM2oGoOm-4qpV8YC04X0v-0", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YmQxMWY5NC1jNWJhLTRhNmEtOGZhMi1hZjZkYzk3Y2YyMjgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODF9.WVDBt-zfwVMHCWr78maSGWAyfQZhWOSobX5oXKXLHwg",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1777257148.963437, "expires": 1777442581.61492,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777246348801}" "value": "{\"type\":\"logout\",\"at\":1777431781473}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1777419149107}" "value": "{\"expiresAt\":1777604581743}"
} }
] ]
} }

View File

@@ -7,6 +7,12 @@ import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png') 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<void> {
await page.goto('/')
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
}
async function deleteNamedChildren(request: any, names: string[]) { async function deleteNamedChildren(request: any, names: string[]) {
const res = await request.get('/api/child/list') const res = await request.get('/api/child/list')
const data = await res.json() const data = await res.json()
@@ -38,7 +44,7 @@ test.describe('Create Child', () => {
await deleteNamedChildren(request, ['Alice']) await deleteNamedChildren(request, ['Alice'])
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated // 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') await expect(page).toHaveURL('/parent')
// 2. Click the 'Add Child' FAB // 2. Click the 'Add Child' FAB
@@ -123,7 +129,7 @@ test.describe('Create Child', () => {
await deleteNamedChildren(request, ['Grace']) await deleteNamedChildren(request, ['Grace'])
// 1. Navigate to app root - router redirects to /parent (children list) // 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 page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()

View File

@@ -2,10 +2,16 @@
import { test, expect } from '@playwright/test' 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<void> {
await page.goto('/')
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
}
test.describe('Create Child', () => { test.describe('Create Child', () => {
test('Cancel navigates back without saving', async ({ page }) => { test('Cancel navigates back without saving', async ({ page }) => {
// 1. Navigate to app root - router redirects to /parent (children list) // 1. Navigate to parent list and wait for it to fully load
await page.goto('/') await gotoParentList(page)
await page.getByRole('button', { name: 'Add Child' }).click() await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()

View File

@@ -3,6 +3,12 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { STORAGE_STATE_CC } from '../../e2e-constants' 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<void> {
await page.goto('/')
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
}
test.describe('Create Child', () => { test.describe('Create Child', () => {
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
@@ -17,7 +23,7 @@ test.describe('Create Child', () => {
} }
// 1. Open two browser tabs both on /parent (children list) // 1. Open two browser tabs both on /parent (children list)
await page.goto('/') await gotoParentList(page)
await expect(page).toHaveURL('/parent') await expect(page).toHaveURL('/parent')
const tab2 = await context.newPage() const tab2 = await context.newPage()
@@ -64,7 +70,7 @@ test.describe('Create Child', () => {
} }
// 1. Tab 1: parent mode // 1. Tab 1: parent mode
await page.goto('/') await gotoParentList(page)
await expect(page).toHaveURL('/parent') await expect(page).toHaveURL('/parent')
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1 // 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1

View File

@@ -2,12 +2,18 @@
import { test, expect } from '@playwright/test' 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<void> {
await page.goto('/')
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
}
test.describe('Create Child', () => { test.describe('Create Child', () => {
test.beforeEach(async ({ page }, testInfo) => { test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode') 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 // Navigate to parent list and wait for it to fully load before clicking Add Child
await page.goto('/') await gotoParentList(page)
await page.getByRole('button', { name: 'Add Child' }).click() await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
}) })

View File

@@ -39,6 +39,8 @@ async function getUnauthContext(playwright: any) {
} }
test.describe('Digest Action Token — Error Paths', () => { test.describe('Digest Action Token — Error Paths', () => {
test.describe.configure({ mode: 'serial' })
let childId = '' let childId = ''
let choreId = '' let choreId = ''

View File

@@ -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()
})

View File

@@ -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<void> {
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('User Profile editing', () => {
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
@@ -46,7 +53,7 @@ test.describe('User Profile editing', () => {
}) })
test('Profile page loads with correct data', async ({ page }) => { 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.getByRole('heading', { name: 'User Profile' })).toBeVisible()
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME) 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 }) => { 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() await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
}) })
test('Save is disabled when First Name is empty', async ({ page }) => { 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').fill('')
await page.getByLabel('First Name').blur() 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 }) => { 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').fill('')
await page.getByLabel('Last Name').blur() 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 }) => { 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('First Name').fill('')
await page.getByLabel('Last 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 }) => { 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') 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 }) => { 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('First Name').fill('UpdatedE2E')
await page.getByLabel('Last Name').fill('UpdatedTester') await page.getByLabel('Last Name').fill('UpdatedTester')
@@ -121,25 +128,25 @@ test.describe('User Profile editing', () => {
await dialog.getByRole('button', { name: 'OK' }).click() await dialog.getByRole('button', { name: 'OK' }).click()
// OK navigates back; go back to profile to verify persistence // 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('First Name')).toHaveValue('UpdatedE2E')
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester') await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
}) })
test('Cancel discards unsaved changes', async ({ page }) => { test('Cancel discards unsaved changes', async ({ page }) => {
await page.goto('/parent/profile') await gotoProfile(page)
await page.getByLabel('First Name').fill('Discarded') await page.getByLabel('First Name').fill('Discarded')
await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('button', { name: 'Cancel' }).click()
// Navigate back to verify no changes were saved // 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) await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
}) })
test('Email field is read-only', async ({ page }) => { test('Email field is read-only', async ({ page }) => {
await page.goto('/parent/profile') await gotoProfile(page)
const emailInput = page.locator('#email') const emailInput = page.locator('#email')
await expect(emailInput).toBeDisabled() await expect(emailInput).toBeDisabled()
@@ -147,7 +154,7 @@ test.describe('User Profile editing', () => {
}) })
test('Change profile image (built-in)', async ({ page }) => { test('Change profile image (built-in)', async ({ page }) => {
await page.goto('/parent/profile') await gotoProfile(page)
// Wait for images to load // Wait for images to load
await page.waitForSelector('.selectable-image') await page.waitForSelector('.selectable-image')
@@ -181,7 +188,7 @@ test.describe('User Profile editing', () => {
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click() await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
// Re-visit and confirm selection persists // Re-visit and confirm selection persists
await page.goto('/parent/profile') await gotoProfile(page)
await page.waitForSelector('.selectable-image') await page.waitForSelector('.selectable-image')
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src') const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
expect(selectedSrc).toBeTruthy() expect(selectedSrc).toBeTruthy()
@@ -191,7 +198,7 @@ test.describe('User Profile editing', () => {
}) })
test('Upload a custom profile image', async ({ page }) => { test('Upload a custom profile image', async ({ page }) => {
await page.goto('/parent/profile') await gotoProfile(page)
await page.waitForSelector('.selectable-image') await page.waitForSelector('.selectable-image')
@@ -214,7 +221,7 @@ test.describe('User Profile editing', () => {
}) })
test('Change Password shows email-sent modal', async ({ page }) => { 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() await page.getByRole('button', { name: 'Change Password' }).click()

View File

@@ -1,7 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Test group', () => {
test('seed', async ({ page }) => {
// generate code here.
});
});

View File

@@ -622,10 +622,10 @@ onUnmounted(() => {
:sort-fn="childChoreSort" :sort-fn="childChoreSort"
> >
<template #item="{ item }: { item: ChildTask }"> <template #item="{ item }: { item: ChildTask }">
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span> <span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
>COMPLETED</span >COMPLETED</span
> >
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
<span v-else-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp" <span v-else-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span >PENDING</span
> >

View File

@@ -987,14 +987,14 @@ function goToAssignRewards() {
</Teleport> </Teleport>
</div> </div>
<!-- TOO LATE badge -->
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
<!-- PENDING badge -->
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
<!-- COMPLETED badge --> <!-- COMPLETED badge -->
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp" <span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
>COMPLETED</span >COMPLETED</span
> >
<!-- TOO LATE badge -->
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
<!-- PENDING badge -->
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
<div class="item-name">{{ item.name }}</div> <div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" /> <img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />

View File

@@ -19,6 +19,7 @@ import {
subscribeToPushWithResult, subscribeToPushWithResult,
unsubscribeFromPush, unsubscribeFromPush,
isPushOptedOut, isPushOptedOut,
ensurePushSubscriptionSynced,
} from '@/services/pushSubscription' } from '@/services/pushSubscription'
const router = useRouter() const router = useRouter()
@@ -66,6 +67,16 @@ async function fetchUserProfile() {
if (userImageId.value) { if (userImageId.value) {
await loadAvatarImages(userImageId.value) await loadAvatarImages(userImageId.value)
} }
// Silent startup self-heal: if user has push enabled, ensure browser + backend
// subscription state are synchronized (no permission prompt).
if (
isParentAuthenticated.value &&
data.push_notifications_enabled !== false &&
!isPushOptedOut()
) {
void ensurePushSubscriptionSynced()
}
} catch (e) { } catch (e) {
console.error('Error fetching user profile:', e) console.error('Error fetching user profile:', e)
profileLoading.value = false profileLoading.value = false

View File

@@ -36,6 +36,31 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))) return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)))
} }
function uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
async function postSubscriptionToBackend(subscription: PushSubscription): Promise<boolean> {
const subJson = subscription.toJSON()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const res = await fetch('/api/push-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
timezone,
}),
})
return res.ok
}
/** /**
* Request notification permission and subscribe to push notifications. * Request notification permission and subscribe to push notifications.
* Posts the subscription to the backend, including the user's timezone. * Posts the subscription to the backend, including the user's timezone.
@@ -105,32 +130,79 @@ export async function subscribeToPushWithResult(): Promise<PushSubscribeResult>
} }
} }
const subJson = subscription.toJSON()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
let res: Response
try { try {
res = await fetch('/api/push-subscription', { const ok = await postSubscriptionToBackend(subscription)
method: 'POST', if (!ok) {
headers: { 'Content-Type': 'application/json' }, return { ok: false, reason: 'backend_error', detail: 'HTTP error posting subscription' }
body: JSON.stringify({ }
endpoint: subJson.endpoint,
keys: subJson.keys,
timezone,
}),
})
} catch (fetchErr) { } catch (fetchErr) {
console.warn('[Push] Network error posting subscription:', fetchErr) console.warn('[Push] Network error posting subscription:', fetchErr)
return { ok: false, reason: 'backend_error', detail: String(fetchErr) } return { ok: false, reason: 'backend_error', detail: String(fetchErr) }
} }
if (!res.ok) { return { ok: true }
const body = await res.text().catch(() => '') }
console.warn(`[Push] Backend rejected subscription: HTTP ${res.status}`, body)
return { ok: false, reason: 'backend_error', detail: `HTTP ${res.status}: ${body}` } /**
* Silent self-heal path used on app startup/login.
* - Never prompts for permission.
* - If already subscribed with a stale VAPID key, rotates the browser subscription.
* - Ensures backend has the current endpoint/keys.
*/
export async function ensurePushSubscriptionSynced(): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false
if (Notification.permission !== 'granted') return false
const vapidKey = await fetchVapidPublicKey()
if (!vapidKey) return false
let registration: ServiceWorkerRegistration
try {
await navigator.serviceWorker.register('/sw.js')
registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('SW ready timeout')), 10_000),
),
])
} catch (err) {
console.warn('[Push] Service worker not ready for self-heal:', err)
return false
} }
return { ok: true } const desiredServerKey = urlBase64ToUint8Array(vapidKey)
let subscription = await registration.pushManager.getSubscription()
if (subscription) {
const existingServerKeyBuffer = subscription.options?.applicationServerKey
if (existingServerKeyBuffer) {
const existingServerKey = new Uint8Array(existingServerKeyBuffer)
const keyMatches = uint8ArraysEqual(existingServerKey, desiredServerKey)
if (!keyMatches) {
await subscription.unsubscribe()
subscription = null
}
}
}
if (!subscription) {
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: desiredServerKey,
})
} catch (err) {
console.warn('[Push] Failed to create subscription in self-heal:', err)
return false
}
}
try {
return await postSubscriptionToBackend(subscription)
} catch (err) {
console.warn('[Push] Failed to sync subscription in self-heal:', err)
return false
}
} }
/** /**

View File

@@ -1,4 +1,4 @@
{ {
"status": "interrupted", "status": "passed",
"failedTests": [] "failedTests": []
} }