diff --git a/.github/specs/feat-parent-chore-notifications.md b/.github/specs/feat-parent-chore-notifications.md index b21821f..e994d7f 100644 --- a/.github/specs/feat-parent-chore-notifications.md +++ b/.github/specs/feat-parent-chore-notifications.md @@ -191,7 +191,9 @@ email_digest_enabled: boolean; 6. `ParentView` uses vertically stacked `ScrollingList` sections (not tabs). The existing `scrollTo` + `entityType` query params call `scrollToItem()` on the correct list ref, which scrolls the card into view and centers it. After scrolling, apply a temporary pulse/highlight CSS animation (e.g. a 2-second `@keyframes highlight-pulse` using `--item-card-ready-shadow` or `--accent`) to draw the parent's eye to the target card. The animation class should be removed after it completes. 7. **Parent mode timeout — return URL:** When the router guard redirects a non-parent-authenticated user away from a `/parent/...` deep link (e.g. from a notification body tap or email digest redirect), it saves the intended URL to `localStorage['parentReturnUrl']`. `LoginButton` checks for a pending return URL on mount and auto-opens the PIN modal. On successful PIN entry, `consumePendingReturnUrl()` is called and the user is redirected to the saved deep link instead of the default `/parent`. If the parent cancels the PIN modal, `clearPendingReturnUrl()` removes the saved URL so the prompt does not re-appear. The URL is validated to start with `/parent` before saving to prevent open redirect. 8. Existing SSE in-app toast/badge behavior is unchanged — it remains the secondary channel when the tab is active. -9. On `UserProfile.vue`, add an "Email Digest" toggle below the existing action buttons. When toggled, call `PUT /api/user/profile` with `{ email_digest_enabled: }`. Initialize the toggle state from the `GET /api/user/profile` response (which now includes `email_digest_enabled`). +9. On `UserProfile.vue`, add a **Daily Digest** `ToggleField` and a **Push Notifications** `ToggleField` below the email field. + - **Daily Digest**: When toggled, call `PUT /api/user/profile` with `{ email_digest_enabled: }`. Initialize from `GET /api/user/profile` (`email_digest_enabled` field). + - **Push Notifications**: When toggled on, call `subscribeToPushWithResult()` from `pushSubscription.ts`; on `permission_denied` show an inline error message. When toggled off, call `unsubscribeFromPush()`. Initialize by calling `isSubscribedToPush()` in `onMounted`. Disable the toggle (but keep it visible) when `getPushPermissionState() === 'denied'`. ## Frontend Tests @@ -214,6 +216,13 @@ email_digest_enabled: boolean; - [x] User Profile page shows an "Email Digest" toggle - [x] Toggling Email Digest off calls `PUT /api/user/profile` with `email_digest_enabled: false` - [x] Toggling Email Digest on calls `PUT /api/user/profile` with `email_digest_enabled: true` +- [x] User Profile page shows a "Push Notifications" toggle +- [x] Push Notifications toggle initialises to `true` when the browser already has an active push subscription +- [x] Push Notifications toggle initialises to `false` when no subscription exists +- [x] Toggling Push Notifications on triggers `subscribeToPushWithResult()` and posts subscription to the backend +- [x] Toggling Push Notifications off calls `unsubscribeFromPush()` and removes the subscription from the backend +- [x] Push Notifications toggle is disabled (not hidden) when browser notification permission is `"denied"` +- [x] An inline error message is shown when enabling push notifications is blocked by the browser --- diff --git a/frontend/vue-app/e2e/plans/parent-notifications.plan.md b/frontend/vue-app/e2e/plans/parent-notifications.plan.md index 1b5df57..9682273 100644 --- a/frontend/vue-app/e2e/plans/parent-notifications.plan.md +++ b/frontend/vue-app/e2e/plans/parent-notifications.plan.md @@ -537,19 +537,102 @@ This allows a spec to simulate notification action clicks without OS interaction --- +### 10. User Profile Notification Settings + +**File:** `e2e/mode_parent/notifications/user-profile-notification-settings.spec.ts` + +**Seed:** No child/task setup required. Tests navigate directly to the User Profile page. No `afterAll` cleanup beyond any push subscription state left by the tests (handled internally by `beforeEach`/`afterEach` where needed). + +#### 10.1. Email Digest toggle is visible on User Profile page + +- Navigate to `/parent/profile` (or the route that renders `UserProfile.vue`) +- expect: An element with the label text "Daily Digest" is visible + +#### 10.2. Email Digest toggle initialises from the server value + +- Via authenticated `request`, call `PUT /api/user/profile` with `{ email_digest_enabled: true }` to set a known state +- Navigate to `/parent/profile` +- expect: The Daily Digest toggle is checked/on +- Via authenticated `request`, call `PUT /api/user/profile` with `{ email_digest_enabled: false }` +- Reload the page +- expect: The Daily Digest toggle is unchecked/off + +#### 10.3. Toggling Email Digest off calls the correct API + +- Via `request`, ensure `email_digest_enabled` is `true` before the test +- Navigate to `/parent/profile` +- Intercept `PUT /api/user/profile` with `page.route()` +- Click the Daily Digest toggle to switch it off +- expect: Intercepted PUT body contains `email_digest_enabled: false` +- expect: Toggle visually reflects the off state + +#### 10.4. Toggling Email Digest on calls the correct API + +- Via `request`, ensure `email_digest_enabled` is `false` before the test +- Navigate to `/parent/profile` +- Intercept `PUT /api/user/profile` with `page.route()` +- Click the Daily Digest toggle to switch it on +- expect: Intercepted PUT body contains `email_digest_enabled: true` +- expect: Toggle visually reflects the on state + +#### 10.5. Push Notifications toggle is visible on User Profile page + +- Navigate to `/parent/profile` +- expect: An element with the label text "Push Notifications" is visible + +#### 10.6. Push Notifications toggle initialises to off when no subscription exists + +- Do NOT call `page.context().grantPermissions(['notifications'])` (leave default denied/prompt) +- Navigate to `/parent/profile` +- expect: The Push Notifications toggle is unchecked/off + +#### 10.7. Push Notifications toggle initialises to on when a subscription already exists + +- Call `page.context().grantPermissions(['notifications'])` before navigation +- Intercept `POST /api/push-subscription` and respond `200 OK` (to avoid real push registration) +- Navigate to `/parent/tasks/chores` first (triggers `ParentLayout` subscription POST) +- Then navigate to `/parent/profile` +- expect: The Push Notifications toggle is checked/on + +#### 10.8. Push Notifications toggle is disabled when browser permission is denied + +- Call `page.context().clearPermissions()` then navigate to `/parent/profile` with notifications permission absent (or set to `'denied'` if Playwright supports it) +- expect: The Push Notifications toggle input is disabled (`aria-disabled` or `disabled` attribute set) + +#### 10.9. Enabling Push Notifications toggle subscribes and posts to backend + +- Call `page.context().grantPermissions(['notifications'])` +- Intercept `POST /api/push-subscription` to capture the request and respond `200 OK` +- Navigate to `/parent/profile`; ensure toggle starts off (no prior subscription) +- Click the Push Notifications toggle to turn it on +- expect: `POST /api/push-subscription` is called with a non-empty `endpoint` and `keys` +- expect: Toggle visually reflects the on state + +#### 10.10. Disabling Push Notifications toggle unsubscribes and sends DELETE to backend + +- Set up a push subscription (grant permissions, intercept POST on `/parent/tasks/chores` to get a subscription in place) +- Navigate to `/parent/profile`; toggle should be on +- Intercept `DELETE /api/push-subscription` with `page.route()` +- Click the Push Notifications toggle to turn it off +- expect: `DELETE /api/push-subscription` is called +- expect: Toggle visually reflects the off state + +--- + ## Test Summary -| # | Group | Spec File(s) | Test Cases | -| --- | --------------------------------- | -------------------------------------- | ---------- | -| 1 | Push subscription registration | `push-subscription.spec.ts` | 5 | -| 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 | -| 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 | -| 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 | -| 5 | Reward notification — deny flow | `reward-notification-deny.spec.ts` | 6 | -| 6 | ParentView deep-link navigation | `deep-link-navigation.spec.ts` | 7 | -| 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 | -| 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 | -| 9 | SW push — manual/mock | (no spec file) | checklist | +| # | Group | Spec File(s) | Test Cases | +| --- | --------------------------------- | -------------------------------------------- | ---------- | +| 1 | Push subscription registration | `push-subscription.spec.ts` | 5 | +| 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 | +| 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 | +| 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 | +| 5 | Reward notification — deny flow | `reward-notification-deny.spec.ts` | 6 | +| 6 | ParentView deep-link navigation | `deep-link-navigation.spec.ts` | 7 | +| 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 | +| 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 | +| 9 | SW push — manual/mock | (no spec file) | checklist | +| 10 | User Profile notification toggles | `user-profile-notification-settings.spec.ts` | 10 | ## Implementation Notes diff --git a/frontend/vue-app/src/components/profile/UserProfile.vue b/frontend/vue-app/src/components/profile/UserProfile.vue index eaea354..1bcf484 100644 --- a/frontend/vue-app/src/components/profile/UserProfile.vue +++ b/frontend/vue-app/src/components/profile/UserProfile.vue @@ -15,6 +15,22 @@ -
-
-
- Daily Email Digest - Receive a 9pm summary of pending chore and reward requests with one-click approve/deny - links. -
- -
-
{{ digestError }}
-
-
{{ errorMsg }}
{ email: data.email || '', } emailDigestEnabled.value = data.email_digest_enabled !== false + pushEnabled.value = await isSubscribedToPush() } catch { errorMsg.value = 'Could not load user profile.' } finally { @@ -311,9 +317,8 @@ async function resetPassword() { } } -async function toggleDigest() { +async function toggleDigest(newValue: boolean) { digestError.value = '' - const newValue = !emailDigestEnabled.value savingDigest.value = true try { const res = await fetch('/api/user/profile', { @@ -330,6 +335,28 @@ async function toggleDigest() { } } +async function togglePush(newValue: boolean) { + pushError.value = '' + savingPush.value = true + try { + if (newValue) { + const result = await subscribeToPushWithResult() + if (result.ok) { + pushEnabled.value = true + } else if (result.reason === 'permission_denied') { + pushError.value = 'Notifications are blocked. Enable them in your browser settings.' + } else { + pushError.value = 'Failed to enable push notifications.' + } + } else { + await unsubscribeFromPush() + pushEnabled.value = false + } + } finally { + savingPush.value = false + } +} + function goToChangeParentPin() { router.push({ name: 'ParentPinSetup' }) } @@ -476,80 +503,4 @@ function closeDeleteError() { outline: none; border-color: var(--btn-primary, #4a90e2); } - -.digest-section { - margin-top: 1.5rem; - padding: 1rem 1.2rem; - border-radius: 10px; - background: var(--list-item-bg, #f9f9f9); - border: 1px solid var(--form-input-border, #e6e6e6); -} - -.digest-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.digest-label { - display: flex; - flex-direction: column; - gap: 0.2rem; -} - -.digest-title { - font-weight: 600; - font-size: 0.97rem; - color: var(--text, #1a1a1a); -} - -.digest-desc { - font-size: 0.83rem; - color: var(--text-muted, #666); - line-height: 1.4; -} - -.digest-error { - margin-top: 0.5rem; -} - -.toggle-btn { - flex-shrink: 0; - width: 44px; - height: 24px; - border-radius: 12px; - border: none; - background: var(--form-input-border, #ccc); - cursor: pointer; - position: relative; - transition: background 0.2s; - padding: 0; -} - -.toggle-btn.active { - background: var(--btn-primary, #4a90e2); -} - -.toggle-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.toggle-knob { - display: block; - width: 18px; - height: 18px; - border-radius: 50%; - background: white; - position: absolute; - top: 3px; - left: 3px; - transition: left 0.2s; - pointer-events: none; -} - -.toggle-btn.active .toggle-knob { - left: 23px; -} diff --git a/frontend/vue-app/src/components/shared/ToggleField.vue b/frontend/vue-app/src/components/shared/ToggleField.vue new file mode 100644 index 0000000..0628392 --- /dev/null +++ b/frontend/vue-app/src/components/shared/ToggleField.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/vue-app/src/services/pushSubscription.ts b/frontend/vue-app/src/services/pushSubscription.ts index 698e495..0019f73 100644 --- a/frontend/vue-app/src/services/pushSubscription.ts +++ b/frontend/vue-app/src/services/pushSubscription.ts @@ -172,3 +172,17 @@ export function getPushPermissionState(): NotificationPermission | 'unsupported' if (!('Notification' in window)) return 'unsupported' return Notification.permission } + +/** + * Returns true if the browser currently has an active push subscription. + */ +export async function isSubscribedToPush(): Promise { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false + try { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + return subscription !== null + } catch { + return false + } +}