feat: add enable/disable toggle for chore scheduling in ScheduleModal
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
- Introduced a toggle button to enable or disable chores in the scheduler. - The toggle will be available in both types of schedulers. - Added UI design proposal and considerations for mobile dimensions. - Included E2E tests to ensure toggle state persistence and correct behavior in child view.
This commit is contained in:
73
.github/agents/ui-planner.agent.md
vendored
Normal file
73
.github/agents/ui-planner.agent.md
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: ui-planner
|
||||||
|
description: Expert UI/UX design review and creative alternative layouts for Vue 3 components. Use when asking for design critiques, layout ideas, visual polish, animation suggestions, or CSS improvements on existing .vue files.
|
||||||
|
argument-hint: review this design
|
||||||
|
tools:
|
||||||
|
- read
|
||||||
|
- search
|
||||||
|
- edit
|
||||||
|
- vscode
|
||||||
|
- web
|
||||||
|
- todo
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a Senior UI/UX Architect specializing in clean, highly reactive web applications. Your goal is to provide expert design critiques and creative layout alternatives that are technically compatible with Vue 3 and this project's conventions.
|
||||||
|
|
||||||
|
## 🏗 Project Context
|
||||||
|
|
||||||
|
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
|
||||||
|
|
||||||
|
- Theme tokens: `frontend/vue-app/src/assets/colors.css` — **always read this first** when reviewing a component.
|
||||||
|
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
|
||||||
|
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
|
||||||
|
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
|
||||||
|
|
||||||
|
### Available Design Tokens (from `colors.css`)
|
||||||
|
|
||||||
|
**Brand:** `--primary` (#667eea), `--secondary` (#7257b3), `--accent` (#cbd5e1)
|
||||||
|
**Header:** `--header-bg` (gradient primary → secondary)
|
||||||
|
**Buttons:** `--btn-primary`, `--btn-primary-hover`, `--btn-secondary`, `--btn-secondary-hover`, `--btn-secondary-text`, `--btn-danger`, `--btn-danger-hover`, `--btn-green`, `--btn-green-hover`
|
||||||
|
**List items:** `--list-item-bg`, `--list-item-bg-good` (#8dabfd), `--list-item-bg-bad` (#f98a8a), `--list-item-bg-reward` (#4ed271), `--list-item-border-good`, `--list-item-border-bad`, `--list-item-border-reward`
|
||||||
|
**Forms:** `--form-bg`, `--form-shadow`, `--form-heading`, `--form-label`, `--form-input-bg`, `--form-input-border`
|
||||||
|
**Modals/Cards:** `--modal-bg`, `--modal-shadow`, `--card-bg`, `--card-shadow`, `--card-title`
|
||||||
|
**Feedback:** `--error`, `--error-bg`, `--loading-color`, `--list-loading-color`
|
||||||
|
**FAB:** `--fab-bg`, `--fab-hover-bg`, `--fab-active-bg`
|
||||||
|
|
||||||
|
## 🛠 Technical Constraints
|
||||||
|
|
||||||
|
- **No utility frameworks.** No Tailwind, no Bootstrap.
|
||||||
|
- **Layout:** Flexbox and CSS Grid only.
|
||||||
|
- **Animations:** Vue `<Transition>` / `<TransitionGroup>` for state changes; `@keyframes` for loaders.
|
||||||
|
- **Theming:** Only use `var(--token)` from `colors.css` for any color, shadow, or spacing token. Never hardcode hex values.
|
||||||
|
- **Component-first:** Consider how changes affect child component slots and reusability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
When a component is shared, always **read the file** plus `colors.css` before responding.
|
||||||
|
|
||||||
|
### Step 1 — Design Audit
|
||||||
|
|
||||||
|
Critique the current UI concisely:
|
||||||
|
|
||||||
|
1. **Visual Hierarchy:** Does the most important information catch the eye first?
|
||||||
|
2. **State Clarity:** Are loading / empty / pending / success / error states visually distinct?
|
||||||
|
3. **Consistency:** Does spacing, typography, and color usage feel cohesive with the rest of the app?
|
||||||
|
|
||||||
|
### Step 2 — Creative Exploration
|
||||||
|
|
||||||
|
Offer a range of design directions — not a fixed number, but varied **perspectives**:
|
||||||
|
|
||||||
|
- **Refinement:** Polish what exists for better clarity or polish.
|
||||||
|
- **Layout Shift:** Reorganize spatial relationships of elements.
|
||||||
|
- **Visual Metaphor:** New ways to represent status (icons vs. text vs. color-coding vs. progress indicators).
|
||||||
|
- **Interactivity:** Transitions, micro-animations, or hover states that make the UI feel alive.
|
||||||
|
|
||||||
|
### Step 3 — Implementation Spec
|
||||||
|
|
||||||
|
For any direction the user wants to pursue, produce a complete spec:
|
||||||
|
|
||||||
|
- **Template:** Semantic HTML using existing project components and slot patterns.
|
||||||
|
- **CSS:** Complete `<style scoped>` rules using only `var(--token)` design tokens.
|
||||||
|
- **Logic:** TypeScript / Vue reactivity changes needed (computed props, refs, transitions).
|
||||||
242
.github/specs/feat-schedule-enable-disable.md
vendored
Normal file
242
.github/specs/feat-schedule-enable-disable.md
vendored
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Feature: Chore Schedule Enable/Disable Toggle
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Goal:** Allow parents to pause and resume a chore schedule without deleting it. A paused schedule retains all its configuration (days, intervals, deadlines) but acts as if the chore had no schedule — it shows every day with no deadline or expiry — until re-enabled.
|
||||||
|
|
||||||
|
**User Story:**
|
||||||
|
As a parent, I want to temporarily pause a chore's schedule so my child still sees it every day but it loses its deadline constraints, without losing the schedule configuration I've set up.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
follow .github/copilot-instructions.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### Toggle Placement
|
||||||
|
|
||||||
|
A dedicated row between the `ModalDialog` heading and the mode toggle ("Specific Days" / "Every X Days"):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ 🛏️ Schedule Chore │ heading (unchanged)
|
||||||
|
│ Make your bed │
|
||||||
|
│ │
|
||||||
|
│ Schedule ━━━━━━━━━━━━━━━● ON │ ← new toggle row
|
||||||
|
│ │
|
||||||
|
│ [Specific Days] [Every X Days] │ mode toggle (unchanged)
|
||||||
|
│ ... │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Adds ~44px of height; does not modify `ModalDialog.vue`.
|
||||||
|
- Sits above all config, reflecting its role as the highest-priority decision ("Is this schedule even active?").
|
||||||
|
|
||||||
|
### Toggle Widget
|
||||||
|
|
||||||
|
Clean slider (no text inside the track) + external label:
|
||||||
|
|
||||||
|
- **ON:** Track = `var(--btn-primary)` (#667eea), label = "Enabled"
|
||||||
|
- **OFF:** Track = `var(--form-input-border)` (#cbd5e1), label = "Paused"
|
||||||
|
- **Thumb:** `#fff` with subtle box-shadow
|
||||||
|
- **Dimensions:** 48×24px track, 20px circular thumb, 44px min-height row (mobile touch target)
|
||||||
|
- Uses `role="switch"` and `aria-checked` for accessibility.
|
||||||
|
|
||||||
|
### Dim-on-Disable
|
||||||
|
|
||||||
|
When the toggle is OFF, the form body (mode toggle + all form fields) receives `opacity: 0.45; pointer-events: none`. The action buttons (Cancel / Save) remain fully interactive so the user can save the "paused" state.
|
||||||
|
|
||||||
|
### Color Rationale
|
||||||
|
|
||||||
|
Green (`--btn-green`) is reserved for "reward" semantics in this app. Brand blue (`--btn-primary`) is the correct choice for a generic on/off state, matching the mode toggle, day chips, and primary CTA button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
### Backend Model — `backend/models/chore_schedule.py`
|
||||||
|
|
||||||
|
- [x] Add field `enabled: bool = True` to `ChoreSchedule` dataclass
|
||||||
|
- [x] Add `enabled=d.get('enabled', True)` to `from_dict()`
|
||||||
|
- [x] Add `'enabled': self.enabled` to `to_dict()`
|
||||||
|
|
||||||
|
### Frontend Model — `frontend/vue-app/src/common/models.ts`
|
||||||
|
|
||||||
|
- [x] Add `enabled?: boolean` to `ChoreSchedule` interface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Implementation
|
||||||
|
|
||||||
|
### API — `backend/api/chore_schedule_api.py`
|
||||||
|
|
||||||
|
- [x] Accept `enabled` field in the schedule create/update endpoint payload
|
||||||
|
- [x] Persist `enabled` value through to the database
|
||||||
|
|
||||||
|
### Scheduler Logic
|
||||||
|
|
||||||
|
- [x] In `scheduleUtils.ts` (`isScheduledToday`), return `true` when `schedule.enabled === false` — a paused chore shows every day, like an unscheduled chore:
|
||||||
|
```typescript
|
||||||
|
if (schedule.enabled === false) return true; // paused = show always, like an unscheduled chore
|
||||||
|
```
|
||||||
|
- [x] In `scheduleUtils.ts` (`getDueTimeToday`), return `null` when `schedule.enabled === false` — a paused chore has no deadline or expiry:
|
||||||
|
```typescript
|
||||||
|
if (schedule.enabled === false) return null; // paused = no deadline, no expiry
|
||||||
|
```
|
||||||
|
> Note: Scheduling is evaluated client-side. The original spec referenced Python; this was updated to reflect the actual architecture.
|
||||||
|
> Note: Behavior was revised from "hide chore" to "show always, no deadline" — paused acts like the chore has no schedule at all.
|
||||||
|
|
||||||
|
### SSE Events
|
||||||
|
|
||||||
|
- [x] Existing `chore_schedule_updated` event is sufficient (the payload includes the full schedule object, which will now contain `enabled`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Tests
|
||||||
|
|
||||||
|
### Unit Tests — `backend/tests/test_chore_schedule_api.py`
|
||||||
|
|
||||||
|
- [x] **test_create_schedule_enabled_by_default**: Create a schedule without specifying `enabled`; verify the persisted schedule has `enabled=True`
|
||||||
|
- [x] **test_create_schedule_with_enabled_false**: Create a schedule with `enabled=False`; verify it persists correctly
|
||||||
|
- [x] **test_update_schedule_toggle_enabled**: Create a schedule (enabled), update it with `enabled=False`, verify the change persists; toggle back to `True`, verify again
|
||||||
|
- [x] **test_enabled_field_in_get_response**: Fetch a schedule via GET; verify the `enabled` field is present in the JSON response
|
||||||
|
- [x] **test_enabled_invalid_value_returns_400**: Pass a non-boolean `enabled` value; verify 400 response
|
||||||
|
|
||||||
|
### Model Tests
|
||||||
|
|
||||||
|
- [x] **test_chore_schedule_from_dict_defaults_enabled**: Call `ChoreSchedule.from_dict({...})` without `enabled`; assert `enabled is True`
|
||||||
|
- [x] **test_chore_schedule_from_dict_enabled_false**: Call `ChoreSchedule.from_dict({..., 'enabled': False})`; assert `enabled is False`
|
||||||
|
- [x] **test_chore_schedule_to_dict_includes_enabled**: Create a `ChoreSchedule` instance; call `to_dict()`; assert `'enabled'` key is present with correct value
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
### Component — `frontend/vue-app/src/components/shared/ScheduleModal.vue`
|
||||||
|
|
||||||
|
#### Template
|
||||||
|
|
||||||
|
- [x] Add toggle row between `ModalDialog` open and mode toggle
|
||||||
|
- [x] Wrap mode toggle + form content in `<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">`
|
||||||
|
- [x] Keep action buttons (Cancel / Save) outside the dim wrapper
|
||||||
|
|
||||||
|
#### Script
|
||||||
|
|
||||||
|
- [x] Add `const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)`
|
||||||
|
- [x] Add `const origEnabled = props.schedule?.enabled ?? true` for dirty tracking
|
||||||
|
- [x] Add `if (scheduleEnabled.value !== origEnabled) return true` to `isDirty` computed
|
||||||
|
- [x] Include `enabled: scheduleEnabled.value` in both `setChoreSchedule` payloads (days + interval modes)
|
||||||
|
|
||||||
|
#### CSS
|
||||||
|
|
||||||
|
- [x] Add toggle row styles (`.schedule-toggle-row`, `.toggle-label`)
|
||||||
|
- [x] Add toggle track/thumb styles (`.toggle-track`, `.toggle-track.on`, `.toggle-thumb`)
|
||||||
|
- [x] Add dim wrapper styles (`.schedule-body`, `.schedule-body.disabled`)
|
||||||
|
|
||||||
|
### Design Tokens — `frontend/vue-app/src/assets/colors.css` (optional)
|
||||||
|
|
||||||
|
- [ ] Optionally add reusable toggle tokens (not needed — existing tokens used directly)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Tests
|
||||||
|
|
||||||
|
### scheduleUtils Tests — `frontend/vue-app/src/__tests__/scheduleUtils.spec.ts`
|
||||||
|
|
||||||
|
- [x] **returns true when enabled is false on a scheduled day (days mode)**: A paused schedule always returns `true` regardless of day
|
||||||
|
- [x] **returns true when enabled is false on an unscheduled day (days mode)**: A paused schedule returns `true` even on days that would normally be skipped
|
||||||
|
- [x] **returns true when enabled is false on a hit day (interval mode)**: A paused interval schedule always returns `true`
|
||||||
|
- [x] **returns true when enabled is false on a non-hit day (interval mode)**: A paused interval schedule returns `true` even on non-hit days
|
||||||
|
- [x] **getDueTimeToday returns null when paused**: A paused schedule's `getDueTimeToday` returns `null` — no deadline, no expiry
|
||||||
|
- [x] **treats enabled=undefined as enabled (backward compat)**: Schedules without `enabled` field behave as enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Spec
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Enable/Disable Toggle ────────────────────── */
|
||||||
|
|
||||||
|
.schedule-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--form-label, #444);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: var(--form-input-border, #cbd5e1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track.on {
|
||||||
|
background: var(--btn-primary, #667eea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track.on .toggle-thumb {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dim wrapper when paused ──────────────────── */
|
||||||
|
|
||||||
|
.schedule-body {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-body.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (Definition of Done)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [x] `ChoreSchedule` model has `enabled: bool` field, defaulting to `True`
|
||||||
|
- [x] API accepts and persists `enabled` in create/update
|
||||||
|
- [x] Client-side scheduler: `isScheduledToday` returns `true` for paused — chore shows every day
|
||||||
|
- [x] Client-side scheduler: `getDueTimeToday` returns `null` for paused — no deadline or expiry
|
||||||
|
- [x] All backend unit tests pass (28/28)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [x] Toggle row renders in `ScheduleModal` between heading and mode toggle
|
||||||
|
- [x] Toggle uses `--btn-primary` for ON and `--form-input-border` for OFF
|
||||||
|
- [x] Form body dims with `opacity: 0.45` and `pointer-events: none` when paused
|
||||||
|
- [x] `enabled` field is included in save payloads for both modes
|
||||||
|
- [x] Dirty detection tracks `enabled` changes
|
||||||
|
- [x] Toggle meets 44px minimum touch target
|
||||||
|
- [x] All frontend scheduleUtils tests pass (38/38)
|
||||||
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
@@ -23,6 +23,10 @@
|
|||||||
"chat.tools.terminal.autoApprove": {
|
"chat.tools.terminal.autoApprove": {
|
||||||
"&": true
|
"&": true
|
||||||
},
|
},
|
||||||
|
"terminal.integrated.automationProfile.windows": {
|
||||||
|
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
"python-envs.defaultEnvManager": "ms-python.python:venv",
|
"python-envs.defaultEnvManager": "ms-python.python:venv",
|
||||||
"python-envs.pythonProjects": [],
|
"python-envs.pythonProjects": [],
|
||||||
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
|
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
|
||||||
@@ -34,18 +38,4 @@
|
|||||||
"editor.fontFamily": "JetBrains Mono",
|
"editor.fontFamily": "JetBrains Mono",
|
||||||
"editor.fontSize": 13,
|
"editor.fontSize": 13,
|
||||||
"editor.fontLigatures": true,
|
"editor.fontLigatures": true,
|
||||||
"squarl.bookmarks": [
|
|
||||||
{
|
|
||||||
"name": "seed.spec.ts",
|
|
||||||
"path": "frontend\\vue-app\\e2e\\seed.spec.ts",
|
|
||||||
"description": "seed.spec.ts",
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "file://C:/tmp",
|
|
||||||
"path": "file://C:/tmp",
|
|
||||||
"description": "",
|
|
||||||
"type": "link"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
59
.vscode/tasks.json
vendored
59
.vscode/tasks.json
vendored
@@ -82,6 +82,65 @@
|
|||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PW: Full Test Suite",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test --reporter=line",
|
||||||
|
"isBackground": false,
|
||||||
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PW: Full Suite",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test --reporter=line",
|
||||||
|
"isBackground": false,
|
||||||
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PW: Full Suite (Process)",
|
||||||
|
"type": "process",
|
||||||
|
"command": "powershell.exe",
|
||||||
|
"args": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test --reporter=line 2>&1"
|
||||||
|
],
|
||||||
|
"group": "test",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PW: Penalty Default Test (Process)",
|
||||||
|
"type": "process",
|
||||||
|
"command": "powershell.exe",
|
||||||
|
"args": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
|
||||||
|
],
|
||||||
|
"group": "test",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PW: User Profile Editing Test (Process)",
|
||||||
|
"type": "process",
|
||||||
|
"command": "powershell.exe",
|
||||||
|
"args": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
|
||||||
|
],
|
||||||
|
"group": "test",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,12 @@ ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
|||||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||||
E2E_TEST_PIN = '1234'
|
E2E_TEST_PIN = '1234'
|
||||||
|
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
|
||||||
|
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
|
||||||
|
E2E_DELETE_PIN = '5678'
|
||||||
|
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||||
|
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||||
|
E2E_CC_PIN = '3456'
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(to_email, token):
|
def send_verification_email(to_email, token):
|
||||||
@@ -470,6 +476,52 @@ def logout():
|
|||||||
return resp, 200
|
return resp, 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
|
||||||
|
def e2e_create_delete_user():
|
||||||
|
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
|
||||||
|
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||||
|
return jsonify({'error': 'Not available in production'}), 403
|
||||||
|
|
||||||
|
norm_email = normalize_email(E2E_DELETE_EMAIL)
|
||||||
|
users_db.remove(UserQuery.email == norm_email)
|
||||||
|
user = User(
|
||||||
|
first_name='E2E',
|
||||||
|
last_name='Delete',
|
||||||
|
email=norm_email,
|
||||||
|
password=generate_password_hash(E2E_DELETE_PASSWORD),
|
||||||
|
verified=True,
|
||||||
|
role='user',
|
||||||
|
pin=E2E_DELETE_PIN,
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
return jsonify({'email': norm_email}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
|
||||||
|
def e2e_create_cc_user():
|
||||||
|
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
|
||||||
|
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||||
|
return jsonify({'error': 'Not available in production'}), 403
|
||||||
|
|
||||||
|
norm_email = normalize_email(E2E_CC_EMAIL)
|
||||||
|
# Remove old user and all their children so deleteAllChildren() starts clean.
|
||||||
|
existing = users_db.get(UserQuery.email == norm_email)
|
||||||
|
if existing:
|
||||||
|
child_db.remove(Query().user_id == existing.get('id'))
|
||||||
|
users_db.remove(UserQuery.email == norm_email)
|
||||||
|
user = User(
|
||||||
|
first_name='E2E',
|
||||||
|
last_name='CreateChild',
|
||||||
|
email=norm_email,
|
||||||
|
password=generate_password_hash(E2E_CC_PASSWORD),
|
||||||
|
verified=True,
|
||||||
|
role='user',
|
||||||
|
pin=E2E_CC_PIN,
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
return jsonify({'email': norm_email}), 201
|
||||||
|
|
||||||
|
|
||||||
@auth_api.route('/e2e-seed', methods=['POST'])
|
@auth_api.route('/e2e-seed', methods=['POST'])
|
||||||
def e2e_seed():
|
def e2e_seed():
|
||||||
"""Reset the database and insert a verified test user. Only available outside production."""
|
"""Reset the database and insert a verified test user. Only available outside production."""
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ def set_chore_schedule(child_id, task_id):
|
|||||||
if mode not in ('days', 'interval'):
|
if mode not in ('days', 'interval'):
|
||||||
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
enabled = data.get('enabled', True)
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
if mode == 'days':
|
if mode == 'days':
|
||||||
day_configs = data.get('day_configs', [])
|
day_configs = data.get('day_configs', [])
|
||||||
if not isinstance(day_configs, list):
|
if not isinstance(day_configs, list):
|
||||||
@@ -69,6 +73,7 @@ def set_chore_schedule(child_id, task_id):
|
|||||||
default_hour=default_hour,
|
default_hour=default_hour,
|
||||||
default_minute=default_minute,
|
default_minute=default_minute,
|
||||||
default_has_deadline=default_has_deadline,
|
default_has_deadline=default_has_deadline,
|
||||||
|
enabled=enabled,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
interval_days = data.get('interval_days', 2)
|
interval_days = data.get('interval_days', 2)
|
||||||
@@ -91,6 +96,7 @@ def set_chore_schedule(child_id, task_id):
|
|||||||
interval_has_deadline=interval_has_deadline,
|
interval_has_deadline=interval_has_deadline,
|
||||||
interval_hour=interval_hour,
|
interval_hour=interval_hour,
|
||||||
interval_minute=interval_minute,
|
interval_minute=interval_minute,
|
||||||
|
enabled=enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_extension_for_child_task(child_id, task_id)
|
delete_extension_for_child_task(child_id, task_id)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ def sse_response_for_user(user_id: str):
|
|||||||
# This prevents Werkzeug's dev server from starving other connections.
|
# This prevents Werkzeug's dev server from starving other connections.
|
||||||
message = user_queue.get(timeout=15)
|
message = user_queue.get(timeout=15)
|
||||||
yield message
|
yield message
|
||||||
logger.info(f"Sent message to {user_id} connection {connection_id}")
|
logger.debug(f"Sent message to {user_id} connection {connection_id}")
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
# Send an SSE comment as a keepalive ping to maintain the connection.
|
# Send an SSE comment as a keepalive ping to maintain the connection.
|
||||||
yield b': ping\n\n'
|
yield b': ping\n\n'
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("events.sse").setLevel(logging.WARNING)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||||
#Todo - add prefix to all these routes instead of in each blueprint
|
#Todo - add prefix to all these routes instead of in each blueprint
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class ChoreSchedule(BaseModel):
|
|||||||
interval_hour: int = 0
|
interval_hour: int = 0
|
||||||
interval_minute: int = 0
|
interval_minute: int = 0
|
||||||
|
|
||||||
|
enabled: bool = True # False = schedule paused; chore won't appear for child
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict) -> 'ChoreSchedule':
|
def from_dict(cls, d: dict) -> 'ChoreSchedule':
|
||||||
return cls(
|
return cls(
|
||||||
@@ -59,6 +61,7 @@ class ChoreSchedule(BaseModel):
|
|||||||
interval_has_deadline=d.get('interval_has_deadline', True),
|
interval_has_deadline=d.get('interval_has_deadline', True),
|
||||||
interval_hour=d.get('interval_hour', 0),
|
interval_hour=d.get('interval_hour', 0),
|
||||||
interval_minute=d.get('interval_minute', 0),
|
interval_minute=d.get('interval_minute', 0),
|
||||||
|
enabled=d.get('enabled', True),
|
||||||
id=d.get('id'),
|
id=d.get('id'),
|
||||||
created_at=d.get('created_at'),
|
created_at=d.get('created_at'),
|
||||||
updated_at=d.get('updated_at'),
|
updated_at=d.get('updated_at'),
|
||||||
@@ -79,5 +82,6 @@ class ChoreSchedule(BaseModel):
|
|||||||
'interval_has_deadline': self.interval_has_deadline,
|
'interval_has_deadline': self.interval_has_deadline,
|
||||||
'interval_hour': self.interval_hour,
|
'interval_hour': self.interval_hour,
|
||||||
'interval_minute': self.interval_minute,
|
'interval_minute': self.interval_minute,
|
||||||
|
'enabled': self.enabled,
|
||||||
})
|
})
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -319,3 +319,96 @@ def test_extend_chore_time_missing_date(client):
|
|||||||
def test_extend_chore_time_bad_child(client):
|
def test_extend_chore_time_bad_child(client):
|
||||||
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
|
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# enabled field
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_schedule_enabled_by_default(client):
|
||||||
|
"""Omitting 'enabled' should persist as True."""
|
||||||
|
payload = {"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]}
|
||||||
|
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_schedule_with_enabled_false(client):
|
||||||
|
"""Explicitly setting enabled=False persists correctly."""
|
||||||
|
payload = {
|
||||||
|
"mode": "days",
|
||||||
|
"day_configs": [{"day": 1, "hour": 8, "minute": 0}],
|
||||||
|
"enabled": False,
|
||||||
|
}
|
||||||
|
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_schedule_toggle_enabled(client):
|
||||||
|
"""Toggle enabled off, then back on; both changes persist."""
|
||||||
|
base = {"mode": "days", "day_configs": [{"day": 2, "hour": 9, "minute": 0}]}
|
||||||
|
|
||||||
|
r1 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": True})
|
||||||
|
assert r1.get_json()["enabled"] is True
|
||||||
|
|
||||||
|
r2 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": False})
|
||||||
|
assert r2.get_json()["enabled"] is False
|
||||||
|
|
||||||
|
r3 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": True})
|
||||||
|
assert r3.get_json()["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_field_in_get_response(client):
|
||||||
|
"""GET response includes the 'enabled' field."""
|
||||||
|
payload = {"mode": "interval", "interval_days": 2, "anchor_date": "", "enabled": False}
|
||||||
|
client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||||
|
|
||||||
|
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "enabled" in data
|
||||||
|
assert data["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_invalid_value_returns_400(client):
|
||||||
|
"""Non-boolean value for enabled returns 400."""
|
||||||
|
payload = {"mode": "days", "day_configs": [], "enabled": "yes"}
|
||||||
|
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ChoreSchedule model: enabled serialization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_chore_schedule_from_dict_defaults_enabled():
|
||||||
|
"""from_dict without 'enabled' defaults to True."""
|
||||||
|
from models.chore_schedule import ChoreSchedule
|
||||||
|
s = ChoreSchedule.from_dict({
|
||||||
|
"child_id": "c1", "task_id": "t1", "mode": "days",
|
||||||
|
"day_configs": [], "interval_days": 2, "anchor_date": "",
|
||||||
|
"interval_has_deadline": True, "interval_hour": 0, "interval_minute": 0,
|
||||||
|
})
|
||||||
|
assert s.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_chore_schedule_from_dict_enabled_false():
|
||||||
|
"""from_dict with enabled=False preserves the value."""
|
||||||
|
from models.chore_schedule import ChoreSchedule
|
||||||
|
s = ChoreSchedule.from_dict({
|
||||||
|
"child_id": "c1", "task_id": "t1", "mode": "days",
|
||||||
|
"day_configs": [], "interval_days": 2, "anchor_date": "",
|
||||||
|
"interval_has_deadline": True, "interval_hour": 0, "interval_minute": 0,
|
||||||
|
"enabled": False,
|
||||||
|
})
|
||||||
|
assert s.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_chore_schedule_to_dict_includes_enabled():
|
||||||
|
"""to_dict includes the 'enabled' key with the correct value."""
|
||||||
|
from models.chore_schedule import ChoreSchedule
|
||||||
|
s = ChoreSchedule(child_id="c1", task_id="t1", mode="days", enabled=False)
|
||||||
|
d = s.to_dict()
|
||||||
|
assert "enabled" in d
|
||||||
|
assert d["enabled"] is False
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask_mail import Mail, Message
|
from flask_mail import Mail, Message
|
||||||
|
import os
|
||||||
|
|
||||||
def send_verification_email(to_email: str, token: str) -> None:
|
def send_verification_email(to_email: str, token: str) -> None:
|
||||||
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
||||||
@@ -11,6 +12,8 @@ def send_verification_email(to_email: str, token: str) -> None:
|
|||||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
if os.environ.get('DB_ENV') =='e2e':
|
||||||
|
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
|
||||||
Mail(current_app).send(msg)
|
Mail(current_app).send(msg)
|
||||||
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
|
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -26,6 +29,8 @@ def send_reset_password_email(to_email: str, token: str) -> None:
|
|||||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
if os.environ.get('DB_ENV') =='e2e':
|
||||||
|
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
|
||||||
Mail(current_app).send(msg)
|
Mail(current_app).send(msg)
|
||||||
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
|
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -50,6 +55,8 @@ def send_pin_setup_email(to_email: str, code: str) -> None:
|
|||||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
if os.environ.get('DB_ENV') =='e2e':
|
||||||
|
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
|
||||||
Mail(current_app).send(msg)
|
Mail(current_app).send(msg)
|
||||||
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
|
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
39
frontend/vue-app/e2e/.auth/user-cc.json
Normal file
39
frontend/vue-app/e2e/.auth/user-cc.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"cookies": [
|
||||||
|
{
|
||||||
|
"name": "refresh_token",
|
||||||
|
"value": "icI1wOmbV5D-snH1lnz75Z-IGK-DuzK-RR_rKzjvvMs",
|
||||||
|
"domain": "localhost",
|
||||||
|
"path": "/api/auth",
|
||||||
|
"expires": 1781792623.050125,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Strict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "access_token",
|
||||||
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0NmNmMmU0Ni1kOGFjLTRlYTItOTExYS1iZjJlMjVjNGI1ZDAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQwMTc1MjN9.KNGwsOe7xEIo-tDbuSP38p3zB1vEB0KZk3mGEtgHs5s",
|
||||||
|
"domain": "localhost",
|
||||||
|
"path": "/",
|
||||||
|
"expires": -1,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Strict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"origin": "https://localhost:5173",
|
||||||
|
"localStorage": [
|
||||||
|
{
|
||||||
|
"name": "authSyncEvent",
|
||||||
|
"value": "{\"type\":\"logout\",\"at\":1774016622749}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parentAuth",
|
||||||
|
"value": "{\"expiresAt\":1774189423292}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
frontend/vue-app/e2e/.auth/user-delete.json
Normal file
39
frontend/vue-app/e2e/.auth/user-delete.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"cookies": [
|
||||||
|
{
|
||||||
|
"name": "refresh_token",
|
||||||
|
"value": "9eQaO12-M1GpCKeqJOmFb07Sms32J1j62DcEb5-neqs",
|
||||||
|
"domain": "localhost",
|
||||||
|
"path": "/api/auth",
|
||||||
|
"expires": 1781792623.010225,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Strict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "access_token",
|
||||||
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiMTFlYjUwNTgtZmYxYi00YjgzLWExMTEtMTJjZjFkZjI4ZjA5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0MDE3NTIyfQ.VCdPbgWlX4Mdr4NSzcyIybyNNbTSLvZE0YQGmQ-owKw",
|
||||||
|
"domain": "localhost",
|
||||||
|
"path": "/",
|
||||||
|
"expires": -1,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Strict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"origin": "https://localhost:5173",
|
||||||
|
"localStorage": [
|
||||||
|
{
|
||||||
|
"name": "authSyncEvent",
|
||||||
|
"value": "{\"type\":\"logout\",\"at\":1774016622727}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parentAuth",
|
||||||
|
"value": "{\"expiresAt\":1774189423292}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "zSbcFMeA59rQCWIkZEDsLO54zjpNu1M0mH0AsHq0Unc",
|
"value": "YAXw1Kh8dLOuSYc1ZW_sgYHWRzo0XivYXmAHAr2RV-o",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1781644571.72276,
|
"expires": 1781792619.603786,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjMDFmNWJhYS1kMGQ3LTQwYzctOTE2OC1mZGY0M2FiNTVmMzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4Njk0NzF9._LZhytX_HqOpeFQ8XGlHqTz6efhIUDZa2Dp7SB-acqE",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzMzAwYjVjNy0yMzIxLTQxMWMtOWZiZC03NzY4ZjM4NTI1OWYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQwMTc1MTl9.2XDC_S2IuKeF_S9HTeHEOCWlUAtlrGpByLdIwrtoxfw",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": -1,
|
"expires": -1,
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1773868571358}"
|
"value": "{\"type\":\"logout\",\"at\":1774016619428}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1774041371935}"
|
"value": "{\"expiresAt\":1774189419776}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
42
frontend/vue-app/e2e/auth-cc.setup.ts
Normal file
42
frontend/vue-app/e2e/auth-cc.setup.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { test as setup } from '@playwright/test'
|
||||||
|
import { STORAGE_STATE_CC, E2E_CC_EMAIL, E2E_CC_PASSWORD, E2E_CC_PIN } from './e2e-constants'
|
||||||
|
|
||||||
|
const BACKEND = 'http://localhost:5000'
|
||||||
|
|
||||||
|
setup('authenticate create-child isolated user', async ({ page }) => {
|
||||||
|
// Create the isolated create-child test user (separate from the main E2E user).
|
||||||
|
// This user starts with no children so deleteAllChildren() in the empty-state test
|
||||||
|
// never interferes with concurrent tests running under the main E2E user.
|
||||||
|
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-cc-user`)
|
||||||
|
if (!createRes.ok()) {
|
||||||
|
throw new Error(`e2e-create-cc-user failed: ${createRes.status()} ${await createRes.text()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/auth/login')
|
||||||
|
await page.getByLabel('Email address').fill(E2E_CC_EMAIL)
|
||||||
|
await page.getByLabel('Password').fill(E2E_CC_PASSWORD)
|
||||||
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/(parent|child)/)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||||
|
|
||||||
|
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||||
|
await pinInput.waitFor({ timeout: 5000 })
|
||||||
|
await pinInput.fill(E2E_CC_PIN)
|
||||||
|
await page.getByLabel('Stay in parent mode on this device').check()
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/parent(\/|$)/)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
|
||||||
|
} catch (e) {
|
||||||
|
await page.screenshot({ path: 'auth-setup-cc-parent-fail.png' })
|
||||||
|
throw new Error(
|
||||||
|
'CC user parent mode not reached after PIN entry. See auth-setup-cc-parent-fail.png for details.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.context().storageState({ path: STORAGE_STATE_CC })
|
||||||
|
})
|
||||||
47
frontend/vue-app/e2e/auth-delete.setup.ts
Normal file
47
frontend/vue-app/e2e/auth-delete.setup.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { test as setup } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
STORAGE_STATE_DELETE,
|
||||||
|
E2E_DELETE_EMAIL,
|
||||||
|
E2E_DELETE_PASSWORD,
|
||||||
|
E2E_DELETE_PIN,
|
||||||
|
} from './e2e-constants'
|
||||||
|
|
||||||
|
const BACKEND = 'http://localhost:5000'
|
||||||
|
|
||||||
|
setup('authenticate delete user', async ({ page }) => {
|
||||||
|
// Create the isolated delete-test user (separate from the main E2E user)
|
||||||
|
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-delete-user`)
|
||||||
|
if (!createRes.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`e2e-create-delete-user failed: ${createRes.status()} ${await createRes.text()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/auth/login')
|
||||||
|
await page.getByLabel('Email address').fill(E2E_DELETE_EMAIL)
|
||||||
|
await page.getByLabel('Password').fill(E2E_DELETE_PASSWORD)
|
||||||
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/(parent|child)/)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||||
|
|
||||||
|
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||||
|
await pinInput.waitFor({ timeout: 5000 })
|
||||||
|
await pinInput.fill(E2E_DELETE_PIN)
|
||||||
|
await page.getByLabel('Stay in parent mode on this device').check()
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/parent(\/|$)/)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
|
||||||
|
} catch (e) {
|
||||||
|
await page.screenshot({ path: 'auth-setup-delete-parent-fail.png' })
|
||||||
|
throw new Error(
|
||||||
|
'Delete user parent mode not reached after PIN entry. See auth-setup-delete-parent-fail.png for details.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.context().storageState({ path: STORAGE_STATE_DELETE })
|
||||||
|
})
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
export const STORAGE_STATE = 'e2e/.auth/user.json'
|
export const STORAGE_STATE = 'e2e/.auth/user.json'
|
||||||
export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
|
export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
|
||||||
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
|
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
|
||||||
|
export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json'
|
||||||
|
export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json'
|
||||||
export const E2E_EMAIL = 'e2e@test.com'
|
export const E2E_EMAIL = 'e2e@test.com'
|
||||||
export const E2E_PASSWORD = 'E2eTestPass1!'
|
export const E2E_PASSWORD = 'E2eTestPass1!'
|
||||||
export const E2E_PIN = '1234'
|
export const E2E_PIN = '1234'
|
||||||
export const E2E_FIRST_NAME = 'E2E'
|
export const E2E_FIRST_NAME = 'E2E'
|
||||||
|
export const E2E_DELETE_EMAIL = 'e2e-delete@test.com'
|
||||||
|
export const E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
|
||||||
|
export const E2E_DELETE_PIN = '5678'
|
||||||
|
export const E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||||
|
export const E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||||
|
export const E2E_CC_PIN = '3456'
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 6: Child Mode Chore Visibility & States
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
setDaysSchedule,
|
||||||
|
choreCard,
|
||||||
|
choreSection,
|
||||||
|
todayDayIndex,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
goToChildView,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ChildCardChild'
|
||||||
|
const CHILD_ALWAYS = 'ChildAlwaysChore'
|
||||||
|
const CHILD_TODAY = 'ChildTodayChore'
|
||||||
|
const CHILD_NOT_TODAY = 'ChildNotTodayChore'
|
||||||
|
const CHILD_EXPIRED = 'ChildExpiredChore'
|
||||||
|
const CHILD_ANYTIME = 'ChildAnytimeChore'
|
||||||
|
|
||||||
|
test.describe('Child Mode — Chore Visibility and Schedule States', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let alwaysId = ''
|
||||||
|
let todayId = ''
|
||||||
|
let notTodayId = ''
|
||||||
|
let expiredId = ''
|
||||||
|
let anytimeId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
alwaysId = await createChoreTask(request, CHILD_ALWAYS)
|
||||||
|
todayId = await createChoreTask(request, CHILD_TODAY)
|
||||||
|
notTodayId = await createChoreTask(request, CHILD_NOT_TODAY)
|
||||||
|
expiredId = await createChoreTask(request, CHILD_EXPIRED)
|
||||||
|
anytimeId = await createChoreTask(request, CHILD_ANYTIME)
|
||||||
|
|
||||||
|
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
|
||||||
|
|
||||||
|
const today = todayDayIndex()
|
||||||
|
const nonToday = nonTodayDayIndex()
|
||||||
|
|
||||||
|
// ChildTodayChore: scheduled for today, deadline 23:59
|
||||||
|
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
|
||||||
|
|
||||||
|
// ChildNotTodayChore: scheduled for a non-today day
|
||||||
|
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
|
||||||
|
|
||||||
|
// ChildExpiredChore: scheduled for today, deadline 00:01 (guaranteed expired)
|
||||||
|
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||||
|
|
||||||
|
// ChildAnytimeChore: scheduled for today, no deadline
|
||||||
|
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
|
||||||
|
|
||||||
|
// ChildAlwaysChore: no schedule
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
|
||||||
|
if (id) await request.delete(`/api/task/${id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6.1 Chore with no schedule — visible normally in child mode', async ({ page }) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, CHILD_ALWAYS)
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6.2 Chore scheduled for today — visible with "Due by" label', async ({ page }) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, CHILD_TODAY)
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6.3 Chore not scheduled for today — HIDDEN in child mode', async ({ page }) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
|
||||||
|
// Wait for the chores section to fully load (uses other cards as anchor)
|
||||||
|
const alwaysCard = choreCard(page, CHILD_ALWAYS)
|
||||||
|
await expect(alwaysCard).toBeVisible()
|
||||||
|
|
||||||
|
// Off-day chore must not appear at all
|
||||||
|
await expect(
|
||||||
|
choreSection(page).locator('.item-card').filter({ hasText: CHILD_NOT_TODAY }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6.4 Chore with expired deadline — grayed out with TOO LATE badge (not hidden)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, CHILD_EXPIRED)
|
||||||
|
|
||||||
|
// Expired chore is still visible (was scheduled for today)
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card).toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6.5 Chore with "Anytime" — visible, no badge, no "Due by"', async ({ page }) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, CHILD_ANYTIME)
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6.6 No kebab menu or schedule controls in child mode', async ({ page }) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, CHILD_TODAY)
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
|
||||||
|
// Click the card — no Options button should appear
|
||||||
|
await card.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await expect(card.getByRole('button', { name: 'Options' })).not.toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Schedule' })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 8: Extend Time
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
setDaysSchedule,
|
||||||
|
choreCard,
|
||||||
|
openChoreOptions,
|
||||||
|
todayDayIndex,
|
||||||
|
todayISO,
|
||||||
|
goToChildView,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ExtendTimeChild'
|
||||||
|
const EXPIRED_NAME = 'ExtendTimeChore'
|
||||||
|
const FUTURE_NAME = 'FutureDeadlineChore'
|
||||||
|
|
||||||
|
test.describe('Extend Time', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let expiredId = ''
|
||||||
|
let futureId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
expiredId = await createChoreTask(request, EXPIRED_NAME)
|
||||||
|
futureId = await createChoreTask(request, FUTURE_NAME)
|
||||||
|
|
||||||
|
await assignChore(request, childId, [expiredId, futureId])
|
||||||
|
|
||||||
|
const today = todayDayIndex()
|
||||||
|
|
||||||
|
// ExpiredChore: scheduled for today, deadline at 00:01 (guaranteed past)
|
||||||
|
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||||
|
|
||||||
|
// FutureChore: scheduled for today, deadline at 23:59 (not expired)
|
||||||
|
await setDaysSchedule(request, childId, futureId, [today], { hour: 23, minute: 59 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (expiredId) await request.delete(`/api/task/${expiredId}`)
|
||||||
|
if (futureId) await request.delete(`/api/task/${futureId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.1 "Extend Time" appears in kebab menu only when chore is expired', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, EXPIRED_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Expired chore shows TOO LATE badge
|
||||||
|
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||||
|
|
||||||
|
await openChoreOptions(page, card)
|
||||||
|
await expect(page.getByRole('button', { name: 'Extend Time' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.2 "Extend Time" is absent when chore is not expired', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, FUTURE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
await openChoreOptions(page, card)
|
||||||
|
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.3 Clicking "Extend Time" removes the TOO LATE badge', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, EXPIRED_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||||
|
|
||||||
|
await openChoreOptions(page, card)
|
||||||
|
await page.getByRole('button', { name: 'Extend Time' }).click()
|
||||||
|
|
||||||
|
// After extension the badge disappears and card is no longer grayed out
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.4 Extended chore no longer shows "Due by" label', async ({ page }) => {
|
||||||
|
// Card was extended in 8.3 so this verifies the extension persists
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, EXPIRED_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||||
|
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.5 "Extend Time" disappears from kebab after extension', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, EXPIRED_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Chore was extended in 8.3 — should no longer be expired
|
||||||
|
await openChoreOptions(page, card)
|
||||||
|
await expect(page.getByRole('button', { name: 'Extend Time' })).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.6 Extension effect visible in child mode', async ({ page }) => {
|
||||||
|
// After extending in parent mode (done in 8.3), verify child view
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, EXPIRED_NAME)
|
||||||
|
|
||||||
|
// Chore is still visible (was scheduled for today + extended) and no TOO LATE badge
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8.6b Extending same chore twice via API returns 409', async ({ request }) => {
|
||||||
|
// The chore was already extended in 8.3; a second API extension should fail
|
||||||
|
const res = await request.post(`/api/child/${childId}/task/${expiredId}/extend`, {
|
||||||
|
data: { date: todayISO() },
|
||||||
|
})
|
||||||
|
expect(res.status()).toBe(409)
|
||||||
|
})
|
||||||
|
})
|
||||||
233
frontend/vue-app/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
233
frontend/vue-app/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// shared helpers for chore-scheduler spec files
|
||||||
|
|
||||||
|
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// ─── Date Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function todayISO(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yesterdayISO(): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function futureDateISO(daysAhead = 7): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + daysAhead)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's weekday (0 = Sunday, 6 = Saturday) — matches DayConfig.day */
|
||||||
|
export function todayDayIndex(): number {
|
||||||
|
return new Date().getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A weekday guaranteed not to be today */
|
||||||
|
export function nonTodayDayIndex(): number {
|
||||||
|
return (new Date().getDay() + 1) % 7
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||||
|
const pre = await request.get('/api/child/list')
|
||||||
|
for (const c of (await pre.json()).children ?? []) {
|
||||||
|
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||||
|
}
|
||||||
|
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||||
|
const list = await request.get('/api/child/list')
|
||||||
|
return (
|
||||||
|
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChoreTask(request: APIRequestContext, name: string): Promise<string> {
|
||||||
|
const pre = await request.get('/api/task/list')
|
||||||
|
for (const t of (await pre.json()).tasks ?? []) {
|
||||||
|
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||||
|
}
|
||||||
|
await request.put('/api/task/add', { data: { name, points: 5, type: 'chore' } })
|
||||||
|
const list = await request.get('/api/task/list')
|
||||||
|
return (
|
||||||
|
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignChore(
|
||||||
|
request: APIRequestContext,
|
||||||
|
childId: string,
|
||||||
|
choreIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||||
|
data: { task_ids: choreIds, type: 'chore' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaysScheduleOpts {
|
||||||
|
hour?: number
|
||||||
|
minute?: number
|
||||||
|
hasDeadline?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDaysSchedule(
|
||||||
|
request: APIRequestContext,
|
||||||
|
childId: string,
|
||||||
|
taskId: string,
|
||||||
|
days: number[],
|
||||||
|
opts: DaysScheduleOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
|
||||||
|
const day_configs = days.map((day) => ({ day, hour, minute }))
|
||||||
|
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||||
|
data: {
|
||||||
|
mode: 'days',
|
||||||
|
day_configs,
|
||||||
|
default_hour: hour,
|
||||||
|
default_minute: minute,
|
||||||
|
default_has_deadline: hasDeadline,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntervalScheduleOpts {
|
||||||
|
hour?: number
|
||||||
|
minute?: number
|
||||||
|
hasDeadline?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIntervalSchedule(
|
||||||
|
request: APIRequestContext,
|
||||||
|
childId: string,
|
||||||
|
taskId: string,
|
||||||
|
intervalDays: number,
|
||||||
|
anchorDate: string,
|
||||||
|
opts: IntervalScheduleOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
|
||||||
|
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||||
|
data: {
|
||||||
|
mode: 'interval',
|
||||||
|
interval_days: intervalDays,
|
||||||
|
anchor_date: anchorDate,
|
||||||
|
interval_has_deadline: hasDeadline,
|
||||||
|
interval_hour: hour,
|
||||||
|
interval_minute: minute,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSchedule(
|
||||||
|
request: APIRequestContext,
|
||||||
|
childId: string,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 404 is acceptable (no schedule existed)
|
||||||
|
await request.delete(`/api/child/${childId}/task/${taskId}/schedule`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Locator for the Chores section */
|
||||||
|
export function choreSection(page: Page): Locator {
|
||||||
|
return page.locator('.child-list-container').filter({
|
||||||
|
has: page.locator('h3', { hasText: 'Chores' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locator for a named chore card in the Chores section */
|
||||||
|
export function choreCard(page: Page, name: string): Locator {
|
||||||
|
return choreSection(page)
|
||||||
|
.locator('.item-card')
|
||||||
|
.filter({
|
||||||
|
has: page.locator('.item-name').getByText(name, { exact: true }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the chore card (select it), wait for ready state, then click Options */
|
||||||
|
export async function openChoreOptions(page: Page, card: Locator): Promise<void> {
|
||||||
|
await card.click()
|
||||||
|
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||||
|
await card.getByRole('button', { name: 'Options' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the schedule modal for a named chore card */
|
||||||
|
export async function openScheduleModal(page: Page, card: Locator): Promise<void> {
|
||||||
|
await openChoreOptions(page, card)
|
||||||
|
await page.getByRole('button', { name: 'Schedule' }).click()
|
||||||
|
await expect(page.getByText('Schedule Chore')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a TimePickerPopover to a specific time.
|
||||||
|
* @param timePicker - the `.time-picker-popover` locator wrapping the popover
|
||||||
|
*/
|
||||||
|
export async function setTimePicker(
|
||||||
|
page: Page,
|
||||||
|
timePicker: Locator,
|
||||||
|
hour12: number,
|
||||||
|
minute: number,
|
||||||
|
period: 'AM' | 'PM',
|
||||||
|
): Promise<void> {
|
||||||
|
await timePicker.locator('.time-display').click()
|
||||||
|
// Scope panel to this specific time picker to avoid strict-mode violations
|
||||||
|
const panel = timePicker.locator('.popover-panel')
|
||||||
|
await panel.waitFor()
|
||||||
|
// Use exact regex patterns — getByRole name uses substring matching by default,
|
||||||
|
// so "3" would also match "30" and "1" would match "10", "11", "12" etc.
|
||||||
|
await panel.getByRole('button', { name: new RegExp(`^${period}$`) }).click()
|
||||||
|
await panel.getByRole('button', { name: new RegExp(`^${hour12}$`) }).click()
|
||||||
|
await panel
|
||||||
|
.getByRole('button', { name: new RegExp(`^${String(minute).padStart(2, '0')}$`) })
|
||||||
|
.click()
|
||||||
|
// Close popover by clicking the modal title (outside popover bounds)
|
||||||
|
await page.locator('.modal-title').click()
|
||||||
|
await panel.waitFor({ state: 'hidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the hidden date input and fire a change event */
|
||||||
|
export async function setDateInput(page: Page, isoDate: string): Promise<void> {
|
||||||
|
await page.locator('input.date-input-hidden').evaluate((el: HTMLInputElement, val: string) => {
|
||||||
|
el.value = val
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
}, isoDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the child view for a given child.
|
||||||
|
* Parent-authenticated users are blocked from /child by the auth guard, so we
|
||||||
|
* must clear the parentAuth localStorage key before navigating there.
|
||||||
|
*/
|
||||||
|
export async function goToChildView(page: Page, childId: string): Promise<void> {
|
||||||
|
// Go to the app domain first so we can access its localStorage
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
// Clear the parent auth token — router will now allow /child/:id
|
||||||
|
await page.evaluate(() => localStorage.removeItem('parentAuth'))
|
||||||
|
// Navigate to child view and wait for the chore section to appear
|
||||||
|
await page.goto(`/child/${childId}`)
|
||||||
|
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate (back) to the parent view from any page.
|
||||||
|
* Restores the parentAuth localStorage key so the router allows /parent/:id.
|
||||||
|
*/
|
||||||
|
export async function goToParentView(page: Page, childId: string): Promise<void> {
|
||||||
|
// Restore parent auth so the router guard allows /parent/:id
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'parentAuth',
|
||||||
|
JSON.stringify({ expiresAt: Date.now() + 2 * 24 * 60 * 60 * 1000 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 9: Interval Mode Anchor Date Logic
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
setIntervalSchedule,
|
||||||
|
choreCard,
|
||||||
|
choreSection,
|
||||||
|
todayISO,
|
||||||
|
yesterdayISO,
|
||||||
|
goToChildView,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'IntervalAnchorChild'
|
||||||
|
const CHORE_NAME = 'IntervalAnchorChore'
|
||||||
|
|
||||||
|
test.describe('Schedule Interval Mode — Anchor Date Logic', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.1 Interval every 1 day starting today — chore active today', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
|
||||||
|
hour: 23,
|
||||||
|
minute: 59,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parent mode: not grayed out
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const parentCard = choreCard(page, CHORE_NAME)
|
||||||
|
await parentCard.waitFor()
|
||||||
|
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||||
|
|
||||||
|
// Child mode: visible
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.2 Interval every 2 days starting yesterday — chore NOT active today', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// anchor=yesterday, interval=2: yesterday+2=tomorrow → today is skipped
|
||||||
|
await setIntervalSchedule(request, childId, choreId, 2, yesterdayISO(), {
|
||||||
|
hour: 23,
|
||||||
|
minute: 59,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parent mode: grayed out (not hitting today)
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const parentCard = choreCard(page, CHORE_NAME)
|
||||||
|
await parentCard.waitFor()
|
||||||
|
await expect(parentCard).toHaveClass(/chore-inactive/)
|
||||||
|
|
||||||
|
// Child mode: hidden (not scheduled for today)
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
// Wait for list to finish loading — the chore should be filtered out
|
||||||
|
await expect(choreSection(page).locator('.empty, .scroll-wrapper')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.3 Interval every 2 days starting today — active today', async ({ page, request }) => {
|
||||||
|
// anchor=today, interval=2: diffDays=0, 0%2=0 → hits today
|
||||||
|
await setIntervalSchedule(request, childId, choreId, 2, todayISO(), {
|
||||||
|
hour: 23,
|
||||||
|
minute: 59,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parent mode: not grayed out
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const parentCard = choreCard(page, CHORE_NAME)
|
||||||
|
await parentCard.waitFor()
|
||||||
|
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||||
|
|
||||||
|
// Child mode: visible
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
await expect(choreCard(page, CHORE_NAME)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.4 Interval "Anytime" deadline — no "Due by" label', async ({ page, request }) => {
|
||||||
|
await setIntervalSchedule(request, childId, choreId, 1, todayISO(), {
|
||||||
|
hasDeadline: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Anytime = no due label
|
||||||
|
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 5: Parent Mode Chore Card Schedule States
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
setDaysSchedule,
|
||||||
|
choreCard,
|
||||||
|
todayDayIndex,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ParentCardChild'
|
||||||
|
const ALWAYS_ACTIVE = 'AlwaysActiveChore'
|
||||||
|
const SCHEDULED_TODAY = 'ScheduledTodayChore'
|
||||||
|
const NOT_SCHEDULED_TODAY = 'NotScheduledTodayChore'
|
||||||
|
const EXPIRED_CHORE = 'ExpiredChore'
|
||||||
|
const ANYTIME_CHORE = 'AnytimeChore'
|
||||||
|
|
||||||
|
test.describe('Parent Mode — Chore Card Schedule States', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let alwaysId = ''
|
||||||
|
let todayId = ''
|
||||||
|
let notTodayId = ''
|
||||||
|
let expiredId = ''
|
||||||
|
let anytimeId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
alwaysId = await createChoreTask(request, ALWAYS_ACTIVE)
|
||||||
|
todayId = await createChoreTask(request, SCHEDULED_TODAY)
|
||||||
|
notTodayId = await createChoreTask(request, NOT_SCHEDULED_TODAY)
|
||||||
|
expiredId = await createChoreTask(request, EXPIRED_CHORE)
|
||||||
|
anytimeId = await createChoreTask(request, ANYTIME_CHORE)
|
||||||
|
|
||||||
|
await assignChore(request, childId, [alwaysId, todayId, notTodayId, expiredId, anytimeId])
|
||||||
|
|
||||||
|
const today = todayDayIndex()
|
||||||
|
const nonToday = nonTodayDayIndex()
|
||||||
|
|
||||||
|
// ScheduledTodayChore: scheduled for today, deadline 23:59 (far future)
|
||||||
|
await setDaysSchedule(request, childId, todayId, [today], { hour: 23, minute: 59 })
|
||||||
|
|
||||||
|
// NotScheduledTodayChore: scheduled for a non-today day
|
||||||
|
await setDaysSchedule(request, childId, notTodayId, [nonToday], { hour: 8, minute: 0 })
|
||||||
|
|
||||||
|
// ExpiredChore: scheduled for today, deadline 00:01 (guaranteed past)
|
||||||
|
await setDaysSchedule(request, childId, expiredId, [today], { hour: 0, minute: 1 })
|
||||||
|
|
||||||
|
// AnytimeChore: scheduled for today, no deadline
|
||||||
|
await setDaysSchedule(request, childId, anytimeId, [today], { hasDeadline: false })
|
||||||
|
|
||||||
|
// AlwaysActiveChore: no schedule (already default)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
for (const id of [alwaysId, todayId, notTodayId, expiredId, anytimeId]) {
|
||||||
|
if (id) await request.delete(`/api/task/${id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.1 Chore with no schedule — normal appearance, no annotations', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, ALWAYS_ACTIVE)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.locator('.chore-stamp')).not.toBeVisible()
|
||||||
|
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.2 Chore scheduled for today — shows "Due by" label, not grayed out', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, SCHEDULED_TODAY)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.locator('.due-label')).toHaveText('Due by 11:59 PM', { timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.3 Chore not scheduled for today — grayed out in parent mode but visible', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, NOT_SCHEDULED_TODAY)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Always visible in parent mode
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
// Grayed out because off-day
|
||||||
|
await expect(card).toHaveClass(/chore-inactive/)
|
||||||
|
// No TOO LATE badge (not expired — just off-day)
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.4 Chore with expired deadline — grayed out with TOO LATE badge', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, EXPIRED_CHORE)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card).toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.5 Chore with "Anytime" deadline — no "Due by" label, no TOO LATE badge', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, ANYTIME_CHORE)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.6 Kebab menu always accessible on grayed-out chores', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, NOT_SCHEDULED_TODAY)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Grayed out card still has Options button after clicking
|
||||||
|
await card.click()
|
||||||
|
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||||
|
await expect(card.getByRole('button', { name: 'Options' })).toBeVisible()
|
||||||
|
await card.getByRole('button', { name: 'Options' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||||
|
|
||||||
|
// Schedule modal opens normally
|
||||||
|
await page.getByRole('button', { name: 'Schedule' }).click()
|
||||||
|
await expect(page.getByText('Schedule Chore')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.7 Expired chore cannot be triggered by tapping — no confirm dialog', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, EXPIRED_CHORE)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// First click selects the card even when expired
|
||||||
|
await card.click()
|
||||||
|
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||||
|
|
||||||
|
// Second click attempts to trigger — but expired chores are blocked in triggerTask()
|
||||||
|
await card.click()
|
||||||
|
// Confirm dialog should NOT appear for expired chores
|
||||||
|
await expect(page.locator('.modal-dialog')).not.toBeVisible({ timeout: 1000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 7: Parent vs Child Mode Comparison
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
deleteSchedule,
|
||||||
|
setDaysSchedule,
|
||||||
|
choreCard,
|
||||||
|
choreSection,
|
||||||
|
todayDayIndex,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
goToChildView,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'CompareChild'
|
||||||
|
const VISIBLE_BOTH = 'VisibleBothChore'
|
||||||
|
const PARENT_ONLY = 'ParentOnlyChore'
|
||||||
|
|
||||||
|
test.describe('Parent vs Child Mode Comparison', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let visibleBothId = ''
|
||||||
|
let parentOnlyId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
visibleBothId = await createChoreTask(request, VISIBLE_BOTH)
|
||||||
|
parentOnlyId = await createChoreTask(request, PARENT_ONLY)
|
||||||
|
|
||||||
|
await assignChore(request, childId, [visibleBothId, parentOnlyId])
|
||||||
|
|
||||||
|
const today = todayDayIndex()
|
||||||
|
const nonToday = nonTodayDayIndex()
|
||||||
|
|
||||||
|
// VisibleBothChore: scheduled for today (visible everywhere)
|
||||||
|
await setDaysSchedule(request, childId, visibleBothId, [today], { hour: 23, minute: 59 })
|
||||||
|
|
||||||
|
// ParentOnlyChore: scheduled for a non-today day (grayed/hidden)
|
||||||
|
await setDaysSchedule(request, childId, parentOnlyId, [nonToday], { hour: 8, minute: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (visibleBothId) await request.delete(`/api/task/${visibleBothId}`)
|
||||||
|
if (parentOnlyId) await request.delete(`/api/task/${parentOnlyId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7.1 Parent mode shows all chores including off-day ones', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
|
||||||
|
const visibleCard = choreCard(page, VISIBLE_BOTH)
|
||||||
|
const parentOnlyCard = choreCard(page, PARENT_ONLY)
|
||||||
|
|
||||||
|
await visibleCard.waitFor()
|
||||||
|
await parentOnlyCard.waitFor()
|
||||||
|
|
||||||
|
// Both visible in parent mode
|
||||||
|
await expect(visibleCard).toBeVisible()
|
||||||
|
await expect(parentOnlyCard).toBeVisible()
|
||||||
|
|
||||||
|
// parentOnly is grayed out (off-day), visible is not
|
||||||
|
await expect(parentOnlyCard).toHaveClass(/chore-inactive/)
|
||||||
|
await expect(visibleCard).not.toHaveClass(/chore-inactive/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7.2 Child mode hides off-day chores', async ({ page }) => {
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
|
||||||
|
const visibleCard = choreCard(page, VISIBLE_BOTH)
|
||||||
|
await expect(visibleCard).toBeVisible()
|
||||||
|
|
||||||
|
// Off-day chore is hidden entirely
|
||||||
|
await expect(
|
||||||
|
choreSection(page).locator('.item-card').filter({ hasText: PARENT_ONLY }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7.3 Adding a schedule via parent mode hides chore in child mode on wrong day', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const nonToday = nonTodayDayIndex()
|
||||||
|
|
||||||
|
// Remove today's schedule from VisibleBothChore and set it to non-today
|
||||||
|
await setDaysSchedule(request, childId, visibleBothId, [nonToday], { hour: 8, minute: 0 })
|
||||||
|
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
|
||||||
|
// VisibleBothChore is now off-day — should be hidden in child mode
|
||||||
|
await expect(
|
||||||
|
choreSection(page).locator('.item-card').filter({ hasText: VISIBLE_BOTH }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7.4 Removing a schedule via parent mode shows chore in child mode again', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Delete the schedule via API (acts as if saved with no days)
|
||||||
|
await deleteSchedule(request, childId, visibleBothId)
|
||||||
|
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
|
||||||
|
// No schedule = always visible in child mode
|
||||||
|
const card = choreCard(page, VISIBLE_BOTH)
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7.5 Paused schedule — chore visible in both modes', async ({ page, request }) => {
|
||||||
|
// Re-add a non-today schedule, but paused (enabled=false)
|
||||||
|
await setDaysSchedule(request, childId, parentOnlyId, [nonTodayDayIndex()], {
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parent mode: visible and NOT grayed out (paused = acts unscheduled)
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const parentCard = choreCard(page, PARENT_ONLY)
|
||||||
|
await parentCard.waitFor()
|
||||||
|
await expect(parentCard).toBeVisible()
|
||||||
|
await expect(parentCard).not.toHaveClass(/chore-inactive/)
|
||||||
|
|
||||||
|
// Child mode: also visible (paused = shows every day)
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const childCard = choreCard(page, PARENT_ONLY)
|
||||||
|
await expect(childCard).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 2: Schedule Modal Specific Days Mode
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
deleteSchedule,
|
||||||
|
setDaysSchedule,
|
||||||
|
choreCard,
|
||||||
|
openScheduleModal,
|
||||||
|
setTimePicker,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ScheduleDaysChild'
|
||||||
|
const CHORE_NAME = 'ScheduleDaysChore'
|
||||||
|
|
||||||
|
test.describe('Schedule Modal — Specific Days mode', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteSchedule(request, childId, choreId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.1 Clicking day chips toggles them active/inactive', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
const moChip = page.locator('.day-chips').getByRole('button', { name: 'Mo' })
|
||||||
|
await moChip.click()
|
||||||
|
await expect(moChip).toHaveClass(/active/)
|
||||||
|
|
||||||
|
await moChip.click()
|
||||||
|
await expect(moChip).not.toHaveClass(/active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.2 Default deadline row appears when at least one day is selected', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await expect(page.locator('.default-deadline-row')).not.toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
|
||||||
|
await expect(page.locator('.default-deadline-row')).toBeVisible()
|
||||||
|
await expect(page.locator('.default-deadline-row')).toContainText('Deadline')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.3 Default deadline "Anytime" toggle hides and restores time picker', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Select a day to show the deadline row
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Tu' }).click()
|
||||||
|
await expect(page.locator('.default-deadline-row')).toBeVisible()
|
||||||
|
|
||||||
|
// Time picker is visible by default
|
||||||
|
await expect(
|
||||||
|
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Click "Clear (Anytime)" — time picker hides, "Anytime" label appears
|
||||||
|
await page
|
||||||
|
.locator('.default-deadline-row')
|
||||||
|
.getByRole('button', { name: 'Clear (Anytime)' })
|
||||||
|
.click()
|
||||||
|
await expect(
|
||||||
|
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||||
|
).not.toBeVisible()
|
||||||
|
await expect(page.locator('.default-deadline-row').getByText('Anytime')).toBeVisible()
|
||||||
|
|
||||||
|
// Click "Set deadline" — time picker reappears
|
||||||
|
await page
|
||||||
|
.locator('.default-deadline-row')
|
||||||
|
.getByRole('button', { name: 'Set deadline' })
|
||||||
|
.click()
|
||||||
|
await expect(
|
||||||
|
page.locator('.default-deadline-row').locator('.time-picker-popover'),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.4 Exception time — "Set different time" creates per-day override', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Select Monday and Wednesday
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
|
||||||
|
|
||||||
|
// Wednesday row: click "Set different time"
|
||||||
|
const weRow = page.locator('.exception-row').filter({
|
||||||
|
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
|
||||||
|
})
|
||||||
|
await weRow.getByRole('button', { name: 'Set different time' }).click()
|
||||||
|
|
||||||
|
// Wednesday now shows its own time picker
|
||||||
|
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
|
||||||
|
|
||||||
|
// Monday row still shows the default label (no time picker override)
|
||||||
|
const moRow = page.locator('.exception-row').filter({
|
||||||
|
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
|
||||||
|
})
|
||||||
|
await expect(moRow.locator('.default-label')).toBeVisible()
|
||||||
|
await expect(moRow.locator('.time-picker-popover')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.5 Exception time — "Reset to default" removes the override', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Set up Monday + Wednesday, add Wednesday exception
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'We' }).click()
|
||||||
|
const weRow = page.locator('.exception-row').filter({
|
||||||
|
has: page.locator('.exception-day-name', { hasText: 'Wednesday' }),
|
||||||
|
})
|
||||||
|
await weRow.getByRole('button', { name: 'Set different time' }).click()
|
||||||
|
await expect(weRow.locator('.time-picker-popover')).toBeVisible()
|
||||||
|
|
||||||
|
// Reset Wednesday to default
|
||||||
|
await weRow.getByRole('button', { name: 'Reset to default' }).click()
|
||||||
|
await expect(weRow.locator('.default-label')).toBeVisible()
|
||||||
|
await expect(weRow.locator('.time-picker-popover')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.6 Save with specific days — schedule persists on reopen', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Select Monday and Friday
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Fr' }).click()
|
||||||
|
|
||||||
|
// Set deadline to 03:00 PM
|
||||||
|
const timePicker = page.locator('.default-deadline-row').locator('.time-picker-popover')
|
||||||
|
await setTimePicker(page, timePicker, 3, 0, 'PM')
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
|
||||||
|
'03:00 PM',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.7 Save with zero days deletes the schedule', async ({ page, request }) => {
|
||||||
|
// Seed: create a days schedule so the modal opens with existing days
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 9, minute: 0 })
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Deselect all active chips
|
||||||
|
const chips = page.locator('.day-chips').getByRole('button')
|
||||||
|
for (const chip of await chips.all()) {
|
||||||
|
const cls = await chip.getAttribute('class')
|
||||||
|
if (cls?.includes('active')) await chip.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save (removing schedule)
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate fresh to reset card ready-state, then reopen to verify no days selected
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
for (const chip of await page.locator('.day-chips').getByRole('button').all()) {
|
||||||
|
await expect(chip).not.toHaveClass(/active/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2.8 Dirty detection — changing days enables Save', async ({ page, request }) => {
|
||||||
|
const nonToday = nonTodayDayIndex()
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonToday], { hour: 8, minute: 0 })
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Save should be disabled (nothing changed yet)
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
|
|
||||||
|
// Toggle a different day chip → dirty
|
||||||
|
const otherDay = (nonToday + 1) % 7
|
||||||
|
const dayLabels = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: dayLabels[otherDay] }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 4: Schedule Enable/Disable Toggle
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
deleteSchedule,
|
||||||
|
setDaysSchedule,
|
||||||
|
choreCard,
|
||||||
|
openScheduleModal,
|
||||||
|
todayDayIndex,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
goToChildView,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ScheduleToggleChild'
|
||||||
|
const CHORE_NAME = 'ScheduleToggleChore'
|
||||||
|
|
||||||
|
test.describe('Schedule Enable/Disable Toggle', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteSchedule(request, childId, choreId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.1 Toggle row is visible in the schedule modal', async ({ page, request }) => {
|
||||||
|
// Seed a schedule so the modal has something to show
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await expect(page.locator('.schedule-toggle-row')).toBeVisible()
|
||||||
|
await expect(page.getByRole('switch')).toBeVisible()
|
||||||
|
await expect(page.locator('.toggle-label')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.2 Toggling OFF shows "Paused" label and dims the form body', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Toggle is ON initially
|
||||||
|
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
// Toggle OFF
|
||||||
|
await page.getByRole('switch').click()
|
||||||
|
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||||
|
|
||||||
|
// Form body dims
|
||||||
|
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
|
||||||
|
|
||||||
|
// Cancel/Save buttons remain interactive
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.3 Toggling ON restores "Enabled" label and form body', async ({ page, request }) => {
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Toggle OFF then back ON
|
||||||
|
await page.getByRole('switch').click()
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||||
|
await page.getByRole('switch').click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||||
|
await expect(page.locator('.schedule-body')).not.toHaveClass(/disabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.4 Save paused state — persists on reopen', async ({ page, request }) => {
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Toggle OFF and save
|
||||||
|
await page.getByRole('switch').click()
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Paused')
|
||||||
|
await expect(page.locator('.schedule-body')).toHaveClass(/disabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.5 Dirty detection — toggling enabled state enables/disables Save', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()])
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Save is disabled initially
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
|
|
||||||
|
// Toggle OFF → dirty
|
||||||
|
await page.getByRole('switch').click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||||
|
|
||||||
|
// Toggle back ON → not dirty again
|
||||||
|
await page.getByRole('switch').click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.6 Paused schedule in parent mode — chore is visible and not grayed out', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Schedule for a non-today weekday, but paused (enabled=false)
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Chore is visible
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
// Paused = acts like unscheduled = not grayed out
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.7 Paused schedule in child mode — chore is visible', async ({ page, request }) => {
|
||||||
|
// Non-today schedule, paused
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { enabled: false })
|
||||||
|
|
||||||
|
await goToChildView(page, childId)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
// Paused = isScheduledToday returns true → chore is visible in child mode
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.8 Paused schedule shows no "Due by" label', async ({ page, request }) => {
|
||||||
|
// Schedule for today with a deadline, but paused
|
||||||
|
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], {
|
||||||
|
hour: 23,
|
||||||
|
minute: 59,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// Paused = getDueTimeToday returns null → no "Due by" label
|
||||||
|
await expect(card.locator('.due-label')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 3: Schedule Modal Every X Days Mode
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
deleteSchedule,
|
||||||
|
setIntervalSchedule,
|
||||||
|
choreCard,
|
||||||
|
openScheduleModal,
|
||||||
|
setTimePicker,
|
||||||
|
setDateInput,
|
||||||
|
todayISO,
|
||||||
|
futureDateISO,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ScheduleIntervalChild'
|
||||||
|
const CHORE_NAME = 'ScheduleIntervalChore'
|
||||||
|
|
||||||
|
test.describe('Schedule Modal — Every X Days mode', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteSchedule(request, childId, choreId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('3.1 Interval stepper increments and decrements within 1–7', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||||
|
|
||||||
|
// Default value is 1
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText('1')
|
||||||
|
|
||||||
|
// Decrement is disabled at 1
|
||||||
|
const decBtn = page.locator('.stepper').getByRole('button').first()
|
||||||
|
await expect(decBtn).toBeDisabled()
|
||||||
|
|
||||||
|
// Increment 6 times → value becomes 7
|
||||||
|
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||||
|
for (let i = 0; i < 6; i++) await incBtn.click()
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText('7')
|
||||||
|
|
||||||
|
// Increment is disabled at 7
|
||||||
|
await expect(incBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("3.2 Anchor date field shows today's date by default", async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||||
|
await expect(page.locator('input.date-input-hidden')).toHaveValue(todayISO())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('3.3 Next occurrence preview label updates correctly', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||||
|
|
||||||
|
// Set interval to 2 (click + once from default 1)
|
||||||
|
await page.locator('.stepper').getByRole('button').last().click()
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText('2')
|
||||||
|
|
||||||
|
// Ensure anchor is today
|
||||||
|
await setDateInput(page, todayISO())
|
||||||
|
|
||||||
|
// Next occurrence row should be visible
|
||||||
|
await expect(page.locator('.next-occurrence-label')).toBeVisible()
|
||||||
|
await expect(page.locator('.next-occurrence-label')).toContainText('Next occurrence:')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('3.4 Interval deadline "Anytime" toggle works', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||||
|
|
||||||
|
// Time picker visible by default
|
||||||
|
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
|
||||||
|
|
||||||
|
// Click "Clear (Anytime)"
|
||||||
|
await page
|
||||||
|
.locator('.interval-time-row')
|
||||||
|
.getByRole('button', { name: 'Clear (Anytime)' })
|
||||||
|
.click()
|
||||||
|
await expect(
|
||||||
|
page.locator('.interval-time-row').locator('.time-picker-popover'),
|
||||||
|
).not.toBeVisible()
|
||||||
|
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
|
||||||
|
|
||||||
|
// Click "Set deadline" — time picker reappears
|
||||||
|
await page.locator('.interval-time-row').getByRole('button', { name: 'Set deadline' }).click()
|
||||||
|
await expect(page.locator('.interval-time-row').locator('.time-picker-popover')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('3.5 Save interval schedule — persists on reopen', async ({ page }) => {
|
||||||
|
const anchor = futureDateISO(60) // 60 days ahead — well past today min constraint
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||||
|
|
||||||
|
// Set interval to 3 (click + twice from default 1)
|
||||||
|
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||||
|
await incBtn.click()
|
||||||
|
await incBtn.click()
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText('3')
|
||||||
|
|
||||||
|
// Set anchor date
|
||||||
|
await setDateInput(page, anchor)
|
||||||
|
|
||||||
|
// Set deadline to 04:30 PM
|
||||||
|
const timePicker = page.locator('.interval-time-row').locator('.time-picker-popover')
|
||||||
|
await setTimePicker(page, timePicker, 4, 30, 'PM')
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate fresh to reset card ready-state, then reopen to verify
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText('3')
|
||||||
|
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchor)
|
||||||
|
await expect(page.locator('.interval-time-row').locator('.time-display')).toHaveText('04:30 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('3.6 Dirty detection — changing interval enables Save', async ({ page, request }) => {
|
||||||
|
await setIntervalSchedule(request, childId, choreId, 3, futureDateISO(30))
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Save is disabled (no changes yet)
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
|
|
||||||
|
// Change interval from 3 to 5 (click + twice)
|
||||||
|
const incBtn = page.locator('.stepper').getByRole('button').last()
|
||||||
|
await incBtn.click()
|
||||||
|
await incBtn.click()
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText('5')
|
||||||
|
|
||||||
|
// Save is now enabled
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 11: Schedule Loading / Backward Compatibility
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
deleteSchedule,
|
||||||
|
setIntervalSchedule,
|
||||||
|
choreCard,
|
||||||
|
openScheduleModal,
|
||||||
|
futureDateISO,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'LoadCompatChild'
|
||||||
|
const CHORE_NAME = 'LoadCompatChore'
|
||||||
|
|
||||||
|
test.describe('Schedule Loading — Backward Compatibility', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteSchedule(request, childId, choreId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('11.1 Schedule without "enabled" field defaults to enabled in modal', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Create schedule via raw API call — omit the `enabled` field
|
||||||
|
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||||
|
data: {
|
||||||
|
mode: 'days',
|
||||||
|
day_configs: [{ day: nonTodayDayIndex(), hour: 8, minute: 0 }],
|
||||||
|
default_hour: 8,
|
||||||
|
default_minute: 0,
|
||||||
|
default_has_deadline: true,
|
||||||
|
// enabled intentionally omitted — backend defaults to true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Toggle should default to "Enabled"
|
||||||
|
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('11.2 Schedule with interval_has_deadline=false shows "Anytime" in modal', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await setIntervalSchedule(request, childId, choreId, 2, futureDateISO(30), {
|
||||||
|
hasDeadline: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// "Every X Days" mode is active
|
||||||
|
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||||
|
|
||||||
|
// Anytime label is shown instead of time picker
|
||||||
|
await expect(page.locator('.interval-time-row').getByText('Anytime')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.locator('.interval-time-row').locator('.time-picker-popover'),
|
||||||
|
).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('11.3 Days schedule with per-day exception loads deadline rows correctly', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Monday=08:00, Wednesday=08:00 (default), Friday=10:30 (exception)
|
||||||
|
// Use raw API to set up mixed schedule
|
||||||
|
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||||
|
data: {
|
||||||
|
mode: 'days',
|
||||||
|
day_configs: [
|
||||||
|
{ day: 1, hour: 8, minute: 0 }, // Monday
|
||||||
|
{ day: 3, hour: 8, minute: 0 }, // Wednesday
|
||||||
|
{ day: 5, hour: 10, minute: 30 }, // Friday — exception from default 08:00
|
||||||
|
],
|
||||||
|
default_hour: 8,
|
||||||
|
default_minute: 0,
|
||||||
|
default_has_deadline: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Monday, Wednesday, Friday chips should be active
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'We' })).toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'Fr' })).toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default deadline shows 08:00 AM
|
||||||
|
await expect(page.locator('.default-deadline-row').locator('.time-display')).toHaveText(
|
||||||
|
'08:00 AM',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Friday has its own time picker (exception) — "Reset to default" button visible
|
||||||
|
const frRow = page.locator('.exception-row').filter({
|
||||||
|
has: page.locator('.exception-day-name', { hasText: 'Friday' }),
|
||||||
|
})
|
||||||
|
await expect(frRow.getByRole('button', { name: 'Reset to default' })).toBeVisible()
|
||||||
|
await expect(frRow.locator('.time-display')).toHaveText('10:30 AM')
|
||||||
|
|
||||||
|
// Monday has no exception — shows default label
|
||||||
|
const moRow = page.locator('.exception-row').filter({
|
||||||
|
has: page.locator('.exception-day-name', { hasText: 'Monday' }),
|
||||||
|
})
|
||||||
|
await expect(moRow.locator('.default-label')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('11.4 Interval schedule restores anchor date and interval on reopen', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const anchorDate = '2026-06-15'
|
||||||
|
const intervalDays = 3
|
||||||
|
|
||||||
|
await setIntervalSchedule(request, childId, choreId, intervalDays, anchorDate, {
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// "Every X Days" mode is active
|
||||||
|
await expect(page.getByRole('button', { name: 'Every X Days' })).toHaveClass(/active/)
|
||||||
|
|
||||||
|
// Stepper shows 3
|
||||||
|
await expect(page.locator('.stepper-value')).toHaveText(`${intervalDays}`)
|
||||||
|
|
||||||
|
// Date input has the correct anchor date
|
||||||
|
await expect(page.locator('input.date-input-hidden')).toHaveValue(anchorDate)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 1: Schedule Modal Opening & Structure
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
deleteSchedule,
|
||||||
|
choreCard,
|
||||||
|
openChoreOptions,
|
||||||
|
openScheduleModal,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'ScheduleModalChild'
|
||||||
|
const CHORE_NAME = 'ScheduleModalChore'
|
||||||
|
|
||||||
|
test.describe('Schedule Modal — Opening & Structure', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteSchedule(request, childId, choreId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1.1 Schedule option appears in kebab menu', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openChoreOptions(page, card)
|
||||||
|
await expect(page.getByRole('button', { name: 'Schedule' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1.2 Schedule modal opens with correct title and subtitle', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
await expect(page.locator('.modal-title')).toHaveText('Schedule Chore')
|
||||||
|
await expect(page.locator('.modal-subtitle')).toHaveText(CHORE_NAME)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1.3 Default state: Specific Days mode, no chips selected, toggle Enabled', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Specific Days mode button is active
|
||||||
|
await expect(page.getByRole('button', { name: 'Specific Days' })).toHaveClass(/active/)
|
||||||
|
await expect(page.getByRole('button', { name: 'Every X Days' })).not.toHaveClass(/active/)
|
||||||
|
|
||||||
|
// No day chips are active
|
||||||
|
const chips = page.locator('.day-chips').getByRole('button')
|
||||||
|
for (const chip of await chips.all()) {
|
||||||
|
await expect(chip).not.toHaveClass(/active/)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle is ON and shows "Enabled"
|
||||||
|
await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await expect(page.locator('.toggle-label')).toHaveText('Enabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1.4 Mode toggle switches between Specific Days and Every X Days', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Switch to Every X Days
|
||||||
|
await page.getByRole('button', { name: 'Every X Days' }).click()
|
||||||
|
await expect(page.locator('.interval-form')).toBeVisible()
|
||||||
|
await expect(page.locator('.days-form')).not.toBeVisible()
|
||||||
|
|
||||||
|
// Switch back to Specific Days
|
||||||
|
await page.getByRole('button', { name: 'Specific Days' }).click()
|
||||||
|
await expect(page.locator('.days-form')).toBeVisible()
|
||||||
|
await expect(page.locator('.interval-form')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1.5 Cancel closes the modal without saving', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Select Monday chip
|
||||||
|
await page.locator('.day-chips').getByRole('button', { name: 'Mo' }).click()
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cancel — modal closes
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
await expect(page.locator('.modal-title')).not.toBeVisible()
|
||||||
|
|
||||||
|
// Navigate fresh to reset card ready-state, then reopen modal
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
await expect(page.locator('.day-chips').getByRole('button', { name: 'Mo' })).not.toHaveClass(
|
||||||
|
/active/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('1.6 Save is disabled when form is not dirty', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await openScheduleModal(page, card)
|
||||||
|
|
||||||
|
// Save button is disabled — no changes made
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// spec: e2e/plans/chore-scheduler.plan.md — Section 10: SSE Real-Time Updates
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createChoreTask,
|
||||||
|
assignChore,
|
||||||
|
setDaysSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
choreCard,
|
||||||
|
todayDayIndex,
|
||||||
|
nonTodayDayIndex,
|
||||||
|
todayISO,
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
const CHILD_NAME = 'SSEScheduleChild'
|
||||||
|
const CHORE_NAME = 'SSEScheduleChore'
|
||||||
|
|
||||||
|
test.describe('SSE Real-Time Updates', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let childId = ''
|
||||||
|
let choreId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
childId = await createChild(request, CHILD_NAME)
|
||||||
|
choreId = await createChoreTask(request, CHORE_NAME)
|
||||||
|
await assignChore(request, childId, [choreId])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (childId) await request.delete(`/api/child/${childId}`)
|
||||||
|
if (choreId) await request.delete(`/api/task/${choreId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteSchedule(request, childId, choreId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('10.1 Setting a schedule via API reflects in an already-open parent view', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
|
||||||
|
// No schedule: card should be visible and not grayed out
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/)
|
||||||
|
|
||||||
|
// Set a non-today schedule via API (triggers SSE chore_schedule_modified)
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
|
||||||
|
|
||||||
|
// SSE fires → card should become grayed out (chore-inactive)
|
||||||
|
await expect(card).toHaveClass(/chore-inactive/, { timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('10.2 Deleting a schedule via API un-grays the chore in parent view', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Seed a non-today schedule first
|
||||||
|
await setDaysSchedule(request, childId, choreId, [nonTodayDayIndex()], { hour: 8, minute: 0 })
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await expect(card).toHaveClass(/chore-inactive/)
|
||||||
|
|
||||||
|
// Delete schedule via API
|
||||||
|
await request.delete(`/api/child/${childId}/task/${choreId}/schedule`)
|
||||||
|
|
||||||
|
// SSE fires → card returns to normal
|
||||||
|
await expect(card).not.toHaveClass(/chore-inactive/, { timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('10.3 Extending time via API reflects in an already-open parent view', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Set today schedule with past deadline (chore starts expired)
|
||||||
|
await setDaysSchedule(request, childId, choreId, [todayDayIndex()], { hour: 0, minute: 1 })
|
||||||
|
|
||||||
|
await page.goto(`/parent/${childId}`)
|
||||||
|
const card = choreCard(page, CHORE_NAME)
|
||||||
|
await card.waitFor()
|
||||||
|
await expect(card.getByText('TOO LATE')).toBeVisible()
|
||||||
|
|
||||||
|
// Extend via API
|
||||||
|
await request.post(`/api/child/${childId}/task/${choreId}/extend`, {
|
||||||
|
data: { date: todayISO() },
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE chore_time_extended fires → TOO LATE badge disappears
|
||||||
|
await expect(card.getByText('TOO LATE')).not.toBeVisible({ timeout: 5000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// spec: e2e/plans/create-child.plan.md
|
// spec: e2e/plans/create-child.plan.md
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
import { STORAGE_STATE } from '../../e2e-constants'
|
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
||||||
|
|
||||||
test.describe('Create Child', () => {
|
test.describe('Create Child', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
@@ -68,7 +68,7 @@ test.describe('Create Child', () => {
|
|||||||
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
|
||||||
const childContext = await browser.newContext({ storageState: STORAGE_STATE })
|
const childContext = await browser.newContext({ storageState: STORAGE_STATE_CC })
|
||||||
const tab2 = await childContext.newPage()
|
const tab2 = await childContext.newPage()
|
||||||
|
|
||||||
// Load the app first (to ensure localStorage is seeded from storageState),
|
// Load the app first (to ensure localStorage is seeded from storageState),
|
||||||
|
|||||||
@@ -2,11 +2,32 @@
|
|||||||
// Merged from kindness-convert-default.spec.ts and kindness-delete-default.spec.ts.
|
// Merged from kindness-convert-default.spec.ts and kindness-delete-default.spec.ts.
|
||||||
// Both tests touch "Be good for the day" so they MUST run serially.
|
// Both tests touch "Be good for the day" so they MUST run serially.
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||||
|
|
||||||
|
const BACKEND = 'http://localhost:5000'
|
||||||
|
|
||||||
|
async function cleanupBeGoodTasks(request: APIRequestContext): Promise<void> {
|
||||||
|
const res = await request.get(`${BACKEND}/task/list`)
|
||||||
|
if (!res.ok()) return
|
||||||
|
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
|
||||||
|
await res.json()
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (
|
||||||
|
task.user_id !== null &&
|
||||||
|
(task.name === 'Be good today (edited)' || task.name === 'Be good for the day')
|
||||||
|
) {
|
||||||
|
await request.delete(`${BACKEND}/task/${task.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Default kindness act management', () => {
|
test.describe('Default kindness act management', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await cleanupBeGoodTasks(request)
|
||||||
|
})
|
||||||
|
|
||||||
test('Convert a default kindness act to a user item by editing', async ({ page }) => {
|
test('Convert a default kindness act to a user item by editing', async ({ page }) => {
|
||||||
await page.goto('/parent/tasks/chores')
|
await page.goto('/parent/tasks/chores')
|
||||||
await page.click('text=Kindness Acts')
|
await page.click('text=Kindness Acts')
|
||||||
@@ -45,23 +66,6 @@ test.describe('Default kindness act management', () => {
|
|||||||
await page.goto('/parent/tasks/chores')
|
await page.goto('/parent/tasks/chores')
|
||||||
await page.getByText('Kindness Acts').click()
|
await page.getByText('Kindness Acts').click()
|
||||||
|
|
||||||
// Cleanup: if a previous run left a modified 'Be good for the day' (with delete icon), remove it first
|
|
||||||
while (
|
|
||||||
(await page
|
|
||||||
.getByText('Be good for the day', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]')
|
|
||||||
.count()) > 0
|
|
||||||
) {
|
|
||||||
await page
|
|
||||||
.getByText('Be good for the day', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Verify 'Be good for the day' is the system default (visible, no delete icon)
|
// 1. Verify 'Be good for the day' is the system default (visible, no delete icon)
|
||||||
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
|
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// spec: frontend/vue-app/e2e/plans/parent-item-management.plan.md
|
|
||||||
// seed: e2e/seed.spec.ts
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
test('Convert a default penalty to a user item by editing', async ({ page }) => {
|
|
||||||
await page.goto('/parent/tasks/chores')
|
|
||||||
await page.click('text=Penalties')
|
|
||||||
// locate default penalty
|
|
||||||
await expect(page.locator('text=Fighting')).toBeVisible()
|
|
||||||
await expect(page.locator('text=Fighting >> .. >> button[aria-label="Delete item"]')).toHaveCount(
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
// edit it (click the item itself)
|
|
||||||
await page.getByText('Fighting', { exact: true }).click()
|
|
||||||
await page.locator('#name').fill('Fighting (custom)')
|
|
||||||
await page.locator('#points').fill('15')
|
|
||||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click()
|
|
||||||
// now should have delete option (match the updated name exactly)
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByText('Fighting (custom)', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]'),
|
|
||||||
).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -2,32 +2,33 @@
|
|||||||
// Merged from penalty-convert-default.spec.ts and penalty-delete-default.spec.ts.
|
// Merged from penalty-convert-default.spec.ts and penalty-delete-default.spec.ts.
|
||||||
// Both tests touch "Fighting" so they MUST run serially.
|
// Both tests touch "Fighting" so they MUST run serially.
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||||
|
|
||||||
|
const BACKEND = 'http://localhost:5000'
|
||||||
|
|
||||||
|
async function cleanupFightingTasks(request: APIRequestContext): Promise<void> {
|
||||||
|
const res = await request.get(`${BACKEND}/task/list`)
|
||||||
|
if (!res.ok()) return
|
||||||
|
const { tasks }: { tasks: Array<{ id: string; name: string; user_id: string | null }> } =
|
||||||
|
await res.json()
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.user_id !== null && (task.name === 'Fighting (custom)' || task.name === 'Fighting')) {
|
||||||
|
await request.delete(`${BACKEND}/task/${task.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Default penalty management', () => {
|
test.describe('Default penalty management', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await cleanupFightingTasks(request)
|
||||||
|
})
|
||||||
|
|
||||||
test('Convert a default penalty to a user item by editing', async ({ page }) => {
|
test('Convert a default penalty to a user item by editing', async ({ page }) => {
|
||||||
await page.goto('/parent/tasks/chores')
|
await page.goto('/parent/tasks/chores')
|
||||||
await page.click('text=Penalties')
|
await page.click('text=Penalties')
|
||||||
|
|
||||||
// Cleanup: delete any leftover 'Fighting (custom)' from a prior run
|
|
||||||
while (
|
|
||||||
(await page
|
|
||||||
.getByText('Fighting (custom)', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]')
|
|
||||||
.count()) > 0
|
|
||||||
) {
|
|
||||||
await page
|
|
||||||
.getByText('Fighting (custom)', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
// locate default penalty
|
// locate default penalty
|
||||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -67,23 +68,6 @@ test.describe('Default penalty management', () => {
|
|||||||
await page.goto('/parent/tasks/chores')
|
await page.goto('/parent/tasks/chores')
|
||||||
await page.getByText('Penalties').click()
|
await page.getByText('Penalties').click()
|
||||||
|
|
||||||
// Cleanup: if a previous run left a modified 'Fighting' (with delete icon), remove it first
|
|
||||||
while (
|
|
||||||
(await page
|
|
||||||
.getByText('Fighting', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]')
|
|
||||||
.count()) > 0
|
|
||||||
) {
|
|
||||||
await page
|
|
||||||
.getByText('Fighting', { exact: true })
|
|
||||||
.locator('..')
|
|
||||||
.locator('button[aria-label="Delete item"]')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
await page.getByRole('button', { name: 'Delete', exact: true }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Verify 'Fighting' is the system default (visible, no delete icon)
|
// 1. Verify 'Fighting' is the system default (visible, no delete icon)
|
||||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
// spec: e2e/plans/user-profile.plan.md
|
// spec: e2e/plans/user-profile.plan.md
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
import { E2E_EMAIL, E2E_PASSWORD } from '../../e2e-constants'
|
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
|
||||||
|
|
||||||
const BACKEND = 'http://localhost:5000'
|
|
||||||
|
|
||||||
test.describe('User Profile – Delete Account', () => {
|
test.describe('User Profile – Delete Account', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
test.afterAll(async ({ request }) => {
|
|
||||||
// Re-seed the e2e database to restore the test user for any subsequent test suites
|
|
||||||
await request.post(`${BACKEND}/auth/e2e-seed`)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await page.goto('/parent/profile')
|
||||||
|
|
||||||
@@ -46,7 +39,7 @@ test.describe('User Profile – Delete Account', () => {
|
|||||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||||
|
|
||||||
await page.locator('#confirmEmail').fill(E2E_EMAIL)
|
await page.locator('#confirmEmail').fill(E2E_DELETE_EMAIL)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||||
@@ -87,7 +80,7 @@ test.describe('User Profile – Delete Account', () => {
|
|||||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||||
|
|
||||||
await page.locator('#confirmEmail').fill(E2E_EMAIL)
|
await page.locator('#confirmEmail').fill(E2E_DELETE_EMAIL)
|
||||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
await page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
||||||
|
|
||||||
// Confirmation modal appears
|
// Confirmation modal appears
|
||||||
@@ -104,8 +97,8 @@ test.describe('User Profile – Delete Account', () => {
|
|||||||
test('Login after deletion is blocked with an appropriate error', async ({ page }) => {
|
test('Login after deletion is blocked with an appropriate error', async ({ page }) => {
|
||||||
await page.goto('/auth/login')
|
await page.goto('/auth/login')
|
||||||
|
|
||||||
await page.getByLabel('Email address').fill(E2E_EMAIL)
|
await page.getByLabel('Email address').fill(E2E_DELETE_EMAIL)
|
||||||
await page.getByLabel('Password').fill(E2E_PASSWORD)
|
await page.getByLabel('Password').fill(E2E_DELETE_PASSWORD)
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
|
|
||||||
// Login must fail with a deletion-related error message
|
// Login must fail with a deletion-related error message
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ test.describe('User Profile – editing', () => {
|
|||||||
|
|
||||||
test('Back button navigates back', async ({ page }) => {
|
test('Back button navigates back', async ({ page }) => {
|
||||||
await page.goto('/parent')
|
await page.goto('/parent')
|
||||||
await page.goto('/parent/profile')
|
// Navigate in-app via the profile dropdown so Vue Router has a real history entry.
|
||||||
|
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||||
|
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||||
|
await expect(page).toHaveURL('/parent/profile')
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
|
|||||||
558
frontend/vue-app/e2e/plans/chore-scheduler.plan.md
Normal file
558
frontend/vue-app/e2e/plans/chore-scheduler.plan.md
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
# Chore Scheduler E2E Tests
|
||||||
|
|
||||||
|
## Application Overview
|
||||||
|
|
||||||
|
The chore scheduler allows parents to configure when chores appear for their children and set deadline times. Schedules operate in two modes: **Specific Days** (select weekdays + per-day deadlines with optional exceptions) and **Every X Days** (interval-based with anchor date). Schedules can be **paused** (enabled/disabled toggle) — a paused schedule makes the chore appear every day with no deadline, as if unscheduled. All schedule logic runs client-side; the backend only stores and returns raw schedule data.
|
||||||
|
|
||||||
|
**Key behavioral difference between parent and child mode:**
|
||||||
|
|
||||||
|
- **Parent mode** (`/parent/:id`): All assigned chores are always visible regardless of schedule. Off-day and expired chores are grayed out (`.chore-inactive`). A kebab menu provides Schedule, Edit Points, Extend Time (when expired), and Reset (when completed) options.
|
||||||
|
- **Child mode** (`/child/:id`): Chores not scheduled for today are **hidden entirely** (filtered out client-side). Expired chores show a `TOO LATE` badge and are grayed out. No kebab menu or scheduling controls exist in child mode.
|
||||||
|
|
||||||
|
**Specs covered:**
|
||||||
|
|
||||||
|
- `feat-calendar-chore.md` — base schedule feature
|
||||||
|
- `feat-calendar-schedule-refactor-01.md` — Specific Days UI refactor (day chips, default deadline, exceptions, TimePickerPopover)
|
||||||
|
- `feat-calendar-schedule-refactor-02.md` — Every X Days UI refactor (stepper, DateInputField, anchor date, next occurrence preview, Anytime toggle)
|
||||||
|
- `feat-schedule-enable-disable.md` — enable/disable (pause) toggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Tests live in `e2e/mode_parent/chore-scheduler/`. Each spec creates its own isolated child and chore tasks via the API so files can run in parallel. All created resources are deleted in `afterAll`.
|
||||||
|
|
||||||
|
## Playwright Projects
|
||||||
|
|
||||||
|
| Project | testMatch | Notes |
|
||||||
|
| -------------------------- | -------------------------------------------- | ----------------------- |
|
||||||
|
| `chromium-chore-scheduler` | `/mode_parent/chore-scheduler/.+\.spec\.ts/` | Depends on `setup` only |
|
||||||
|
|
||||||
|
**Config:** Add project to `playwright.config.ts` with `dependencies: ['setup']`. Exclude from the catch-all project via `testIgnore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed Strategy
|
||||||
|
|
||||||
|
Each spec file creates an isolated child and one or more chore tasks via `beforeAll` API calls. Schedule configuration is done via the `PUT /api/child/:childId/task/:taskId/schedule` API. Cleanup (`delete child + tasks`) runs in `afterAll`.
|
||||||
|
|
||||||
|
For tests that need to verify child-mode behavior, the test navigates to `/child/:id` (no parent auth required for child view). For parent-mode behavior, navigate to `/parent/:id`.
|
||||||
|
|
||||||
|
**Time-sensitive tests:** Tests that depend on "Due by" labels or "TOO LATE" badges must set a schedule deadline far in the future (e.g., 23:59) to avoid the chore expiring mid-test, or set a deadline in the past (e.g., 00:01) to guarantee expiry. Use `page.clock` or carefully chosen deadline times to control time-dependent state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### 1. Schedule Modal — Opening & Structure
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/schedule-modal.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ScheduleModalChild` and chore `ScheduleModalChore` via API. Assigns chore to child. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 1.1. Schedule option appears in kebab menu
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- Click the chore card, then click the kebab "Options" button
|
||||||
|
- expect: "Schedule" button is visible in the menu
|
||||||
|
|
||||||
|
#### 1.2. Schedule modal opens with correct title and subtitle
|
||||||
|
|
||||||
|
- Open kebab menu → click "Schedule"
|
||||||
|
- expect: Modal heading shows "Schedule Chore"
|
||||||
|
- expect: Modal subtitle shows the chore's name
|
||||||
|
|
||||||
|
#### 1.3. Default state for new schedule — days mode selected, no days checked
|
||||||
|
|
||||||
|
- Open the Schedule modal for a chore with no existing schedule
|
||||||
|
- expect: "Specific Days" mode button is active
|
||||||
|
- expect: No day chips are in the active state
|
||||||
|
- expect: Enable/disable toggle shows "Enabled" label and is ON
|
||||||
|
|
||||||
|
#### 1.4. Mode toggle switches between Specific Days and Every X Days
|
||||||
|
|
||||||
|
- Open the Schedule modal
|
||||||
|
- Click "Every X Days" button
|
||||||
|
- expect: Interval form is visible (stepper, "Starting on" date field, deadline row)
|
||||||
|
- Click "Specific Days" button
|
||||||
|
- expect: Day chips row is visible
|
||||||
|
|
||||||
|
#### 1.5. Cancel closes the modal without saving
|
||||||
|
|
||||||
|
- Open the Schedule modal, select Monday, click Cancel
|
||||||
|
- Reopen the modal
|
||||||
|
- expect: No days are selected (change was not persisted)
|
||||||
|
|
||||||
|
#### 1.6. Save is disabled when form is not dirty
|
||||||
|
|
||||||
|
- Open the Schedule modal (no existing schedule)
|
||||||
|
- expect: Save button is disabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Schedule Modal — Specific Days Mode
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/schedule-days-mode.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ScheduleDaysChild` and chore `ScheduleDaysChore` via API. Assigns chore. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 2.1. Clicking day chips toggles them active/inactive
|
||||||
|
|
||||||
|
- Open Schedule modal
|
||||||
|
- Click "Mo" chip
|
||||||
|
- expect: "Mo" chip has active styling
|
||||||
|
- Click "Mo" chip again
|
||||||
|
- expect: "Mo" chip is no longer active
|
||||||
|
|
||||||
|
#### 2.2. Default deadline row appears when at least one day is selected
|
||||||
|
|
||||||
|
- Open Schedule modal, no days selected
|
||||||
|
- expect: No deadline row visible
|
||||||
|
- Click "Tu" chip
|
||||||
|
- expect: Deadline row with "Deadline" label and a time picker appears
|
||||||
|
|
||||||
|
#### 2.3. Default deadline "Anytime" toggle hides the time picker
|
||||||
|
|
||||||
|
- Select a day so the deadline row appears
|
||||||
|
- Click "Clear (Anytime)" link
|
||||||
|
- expect: Time picker disappears, "Anytime" label is shown
|
||||||
|
- Click "Set deadline" link
|
||||||
|
- expect: Time picker reappears
|
||||||
|
|
||||||
|
#### 2.4. Exception time — "Set different time" creates per-day override
|
||||||
|
|
||||||
|
- Select Monday and Wednesday
|
||||||
|
- In the day list, click "Set different time" on Wednesday
|
||||||
|
- expect: A separate time picker appears for Wednesday
|
||||||
|
- expect: Monday still shows the default time label
|
||||||
|
|
||||||
|
#### 2.5. Exception time — "Reset to default" removes the override
|
||||||
|
|
||||||
|
- With Wednesday exception set, click "Reset to default" on Wednesday
|
||||||
|
- expect: Wednesday row shows the default time label again
|
||||||
|
|
||||||
|
#### 2.6. Save with specific days — schedule persists on reopen
|
||||||
|
|
||||||
|
- Select Monday and Friday, set default deadline to 03:00 PM, Save
|
||||||
|
- Reopen the Schedule modal
|
||||||
|
- expect: Monday and Friday chips are active
|
||||||
|
- expect: Default deadline shows 03:00 PM
|
||||||
|
|
||||||
|
#### 2.7. Save with zero days selected deletes the schedule
|
||||||
|
|
||||||
|
- Open a schedule that has days selected, uncheck all days, Save
|
||||||
|
- Reopen the modal
|
||||||
|
- expect: No days selected (schedule was removed), form appears as new
|
||||||
|
|
||||||
|
#### 2.8. Dirty detection — changing days enables Save
|
||||||
|
|
||||||
|
- Open modal with an existing schedule
|
||||||
|
- Toggle a different day
|
||||||
|
- expect: Save becomes enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Schedule Modal — Every X Days Mode
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/schedule-interval-mode.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ScheduleIntervalChild` and chore `ScheduleIntervalChore` via API. Assigns chore. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 3.1. Interval stepper increments and decrements within 1–7
|
||||||
|
|
||||||
|
- Switch to "Every X Days" mode
|
||||||
|
- expect: Stepper shows "1" by default
|
||||||
|
- expect: Decrement (−) button is disabled at 1
|
||||||
|
- Click increment (+) 6 times
|
||||||
|
- expect: Value shows "7"
|
||||||
|
- expect: Increment (+) button is disabled at 7
|
||||||
|
|
||||||
|
#### 3.2. Anchor date field shows today's date by default
|
||||||
|
|
||||||
|
- Switch to "Every X Days" mode
|
||||||
|
- expect: "Starting on" date field contains today's ISO date
|
||||||
|
|
||||||
|
#### 3.3. Next occurrence preview label updates correctly
|
||||||
|
|
||||||
|
- Set interval to 2, set anchor date to today
|
||||||
|
- expect: "Next occurrence" label shows a date 2 days from today
|
||||||
|
|
||||||
|
#### 3.4. Interval deadline "Anytime" toggle works
|
||||||
|
|
||||||
|
- Switch to interval mode; deadline time picker is visible by default
|
||||||
|
- Click "Clear (Anytime)"
|
||||||
|
- expect: Time picker disappears, "Anytime" label shown
|
||||||
|
- Click "Set deadline"
|
||||||
|
- expect: Time picker reappears
|
||||||
|
|
||||||
|
#### 3.5. Save interval schedule — persists on reopen
|
||||||
|
|
||||||
|
- Set interval to 3, pick a specific anchor date, set deadline to 04:30 PM, Save
|
||||||
|
- Reopen Schedule modal
|
||||||
|
- expect: "Every X Days" mode is active
|
||||||
|
- expect: Interval shows 3, anchor date matches, deadline shows 04:30 PM
|
||||||
|
|
||||||
|
#### 3.6. Dirty detection — changing interval enables Save
|
||||||
|
|
||||||
|
- Open modal with an existing interval schedule
|
||||||
|
- Change interval from 3 to 5
|
||||||
|
- expect: Save becomes enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Schedule Enable/Disable Toggle
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/schedule-enable-disable.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ScheduleToggleChild` and chore `ScheduleToggleChore` via API. Assigns chore. Creates a "Specific Days" schedule for a non-today weekday via API. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 4.1. Toggle row is visible in the schedule modal
|
||||||
|
|
||||||
|
- Open Schedule modal
|
||||||
|
- expect: Toggle row with "Enabled" label and a switch is visible above the mode buttons
|
||||||
|
|
||||||
|
#### 4.2. Toggling OFF shows "Paused" label and dims the form body
|
||||||
|
|
||||||
|
- Click the toggle switch to OFF
|
||||||
|
- expect: Label changes to "Paused"
|
||||||
|
- expect: Mode toggle and form fields have reduced opacity (dimmed)
|
||||||
|
- expect: Cancel and Save buttons remain fully interactive
|
||||||
|
|
||||||
|
#### 4.3. Toggling ON shows "Enabled" label and restores the form body
|
||||||
|
|
||||||
|
- Toggle OFF, then toggle back ON
|
||||||
|
- expect: Label returns to "Enabled"
|
||||||
|
- expect: Form body opacity is restored
|
||||||
|
|
||||||
|
#### 4.4. Save paused state — persists on reopen
|
||||||
|
|
||||||
|
- Toggle OFF, Save
|
||||||
|
- Reopen Schedule modal
|
||||||
|
- expect: Toggle is OFF, label shows "Paused", form body is dimmed
|
||||||
|
|
||||||
|
#### 4.5. Dirty detection — toggling enabled state enables Save
|
||||||
|
|
||||||
|
- Open an existing schedule (enabled), toggle OFF
|
||||||
|
- expect: Save becomes enabled
|
||||||
|
- Toggle back ON
|
||||||
|
- expect: Save is disabled again (reverted to original)
|
||||||
|
|
||||||
|
#### 4.6. Paused schedule in parent mode — chore is visible (not grayed out)
|
||||||
|
|
||||||
|
- Set up a schedule for a non-today weekday, then pause it via API
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Chore card is visible and NOT grayed out (paused = show always like unscheduled)
|
||||||
|
|
||||||
|
#### 4.7. Paused schedule in child mode — chore is visible (not hidden)
|
||||||
|
|
||||||
|
- Same paused schedule as 4.6
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore card IS visible (paused = scheduled for every day)
|
||||||
|
|
||||||
|
#### 4.8. Paused schedule shows no "Due by" label
|
||||||
|
|
||||||
|
- Pause a schedule that has a deadline set
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: No "Due by" sub-text on the chore card (paused = no deadline)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Parent Mode — Chore Card Schedule States
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/parent-card-states.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ParentCardChild` with multiple chores via API. Assigns chores. Creates various schedules via API. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
**Chores:**
|
||||||
|
|
||||||
|
- `AlwaysActiveChore` — no schedule (always visible, normal appearance)
|
||||||
|
- `ScheduledTodayChore` — schedule includes today's weekday, deadline at 23:59
|
||||||
|
- `NotScheduledTodayChore` — schedule for a weekday that is NOT today
|
||||||
|
- `ExpiredChore` — schedule for today's weekday, deadline at 00:01 (guaranteed past)
|
||||||
|
- `AnytimeChore` — schedule for today, deadline set to "Anytime" (no expiry)
|
||||||
|
|
||||||
|
#### 5.1. Chore with no schedule — normal appearance, no annotations
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: `AlwaysActiveChore` card is visible with normal styling (no grayout, no badge)
|
||||||
|
|
||||||
|
#### 5.2. Chore scheduled for today — shows "Due by" label
|
||||||
|
|
||||||
|
- expect: `ScheduledTodayChore` card is visible
|
||||||
|
- expect: Card shows "Due by 11:59 PM" sub-text
|
||||||
|
- expect: Card is NOT grayed out
|
||||||
|
|
||||||
|
#### 5.3. Chore not scheduled for today — grayed out in parent mode
|
||||||
|
|
||||||
|
- expect: `NotScheduledTodayChore` card IS visible (parent mode shows all chores)
|
||||||
|
- expect: Card has grayed-out styling (`.chore-inactive` class — reduced opacity)
|
||||||
|
|
||||||
|
#### 5.4. Chore with expired deadline — grayed out with TOO LATE badge
|
||||||
|
|
||||||
|
- expect: `ExpiredChore` card is visible
|
||||||
|
- expect: Card has grayed-out styling
|
||||||
|
- expect: "TOO LATE" badge is visible on the card
|
||||||
|
|
||||||
|
#### 5.5. Chore with "Anytime" deadline — no "Due by" label, no TOO LATE
|
||||||
|
|
||||||
|
- expect: `AnytimeChore` card is visible with normal styling
|
||||||
|
- expect: No "Due by" sub-text
|
||||||
|
- expect: No "TOO LATE" badge
|
||||||
|
|
||||||
|
#### 5.6. Kebab menu always accessible on grayed-out chores
|
||||||
|
|
||||||
|
- Click the `NotScheduledTodayChore` card (grayed out)
|
||||||
|
- expect: Kebab "Options" button is visible
|
||||||
|
- Click "Options" → "Schedule" is available
|
||||||
|
- expect: Schedule modal opens normally
|
||||||
|
|
||||||
|
#### 5.7. Chore can still be triggered by parent regardless of schedule state
|
||||||
|
|
||||||
|
- Click the `ExpiredChore` (which has TOO LATE badge)
|
||||||
|
- Click again to activate
|
||||||
|
- expect: Confirmation dialog appears (parent can still trigger expired chores)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Child Mode — Chore Visibility and Schedule States
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/child-card-states.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ChildCardChild` with multiple chores. Assigns chores. Creates schedules via API. Deletes all in `afterAll`. Uses same chore set as test 5 where applicable.
|
||||||
|
|
||||||
|
**Chores:**
|
||||||
|
|
||||||
|
- `ChildAlwaysChore` — no schedule
|
||||||
|
- `ChildTodayChore` — scheduled for today, deadline at 23:59
|
||||||
|
- `ChildNotTodayChore` — scheduled for a non-today weekday
|
||||||
|
- `ChildExpiredChore` — scheduled for today, deadline at 00:01 (past)
|
||||||
|
- `ChildAnytimeChore` — scheduled for today, deadline "Anytime"
|
||||||
|
|
||||||
|
#### 6.1. Chore with no schedule — visible normally in child mode
|
||||||
|
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: `ChildAlwaysChore` card is visible with normal styling
|
||||||
|
|
||||||
|
#### 6.2. Chore scheduled for today — visible with "Due by" label
|
||||||
|
|
||||||
|
- expect: `ChildTodayChore` card is visible
|
||||||
|
- expect: "Due by 11:59 PM" sub-text shown on card
|
||||||
|
- expect: Card is NOT grayed out
|
||||||
|
|
||||||
|
#### 6.3. Chore not scheduled for today — HIDDEN in child mode
|
||||||
|
|
||||||
|
- expect: `ChildNotTodayChore` card is NOT visible (fully hidden, not just grayed out)
|
||||||
|
|
||||||
|
#### 6.4. Chore with expired deadline — grayed out with TOO LATE badge
|
||||||
|
|
||||||
|
- expect: `ChildExpiredChore` card IS visible (still shown, but expired)
|
||||||
|
- expect: Card has grayed-out styling
|
||||||
|
- expect: "TOO LATE" badge visible
|
||||||
|
|
||||||
|
#### 6.5. Chore with "Anytime" — visible, no badge, no "Due by"
|
||||||
|
|
||||||
|
- expect: `ChildAnytimeChore` card visible with normal styling
|
||||||
|
- expect: No "Due by" label, no "TOO LATE" badge
|
||||||
|
|
||||||
|
#### 6.6. No kebab menu or schedule controls in child mode
|
||||||
|
|
||||||
|
- Click on `ChildTodayChore` card
|
||||||
|
- expect: No "Options" button or kebab menu appears on the card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Parent vs Child Mode Comparison
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/parent-vs-child.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `CompareChild` and two chores: `VisibleBothChore` (scheduled for today) and `ParentOnlyChore` (scheduled for a non-today weekday). Assigns both. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 7.1. Parent mode shows all chores including off-day ones
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Both `VisibleBothChore` and `ParentOnlyChore` are visible
|
||||||
|
- expect: `ParentOnlyChore` is grayed out (`chore-inactive`)
|
||||||
|
- expect: `VisibleBothChore` is NOT grayed out
|
||||||
|
|
||||||
|
#### 7.2. Child mode hides off-day chores
|
||||||
|
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: `VisibleBothChore` IS visible
|
||||||
|
- expect: `ParentOnlyChore` is NOT visible (hidden entirely)
|
||||||
|
|
||||||
|
#### 7.3. Adding a schedule via parent mode hides chore in child mode on wrong day
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- Open Schedule for `VisibleBothChore`, set it to a non-today weekday only, Save
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: `VisibleBothChore` is no longer visible in child mode
|
||||||
|
|
||||||
|
#### 7.4. Removing a schedule via parent mode shows chore in child mode again
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- Open Schedule for the chore, uncheck all days (removes schedule), Save
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore is visible again in child mode (no schedule = always shown)
|
||||||
|
|
||||||
|
#### 7.5. Paused schedule — chore visible in both modes
|
||||||
|
|
||||||
|
- Set a non-today schedule and pause it via API
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Chore visible, NOT grayed out (paused = acts unscheduled)
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore IS visible (paused = shows every day)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Extend Time
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/extend-time.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `ExtendTimeChild` and chore `ExtendTimeChore` with a schedule for today and a deadline at 00:01 (guaranteed expired). Assigns chore. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 8.1. "Extend Time" appears in kebab menu only when chore is expired
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: `ExtendTimeChore` shows "TOO LATE" badge (deadline is 00:01, already past)
|
||||||
|
- Click chore card, then "Options"
|
||||||
|
- expect: "Extend Time" menu item is visible
|
||||||
|
|
||||||
|
#### 8.2. "Extend Time" is absent when chore is not expired
|
||||||
|
|
||||||
|
- Set up a second chore with deadline at 23:59 (not yet passed)
|
||||||
|
- Click chore card, then "Options"
|
||||||
|
- expect: "Extend Time" menu item is NOT visible
|
||||||
|
|
||||||
|
#### 8.3. Clicking "Extend Time" removes the TOO LATE badge
|
||||||
|
|
||||||
|
- Click "Extend Time" on the expired chore
|
||||||
|
- expect: "TOO LATE" badge disappears from the card
|
||||||
|
- expect: Card is no longer grayed out
|
||||||
|
|
||||||
|
#### 8.4. Extended chore no longer shows "Due by" label
|
||||||
|
|
||||||
|
- After extending, the chore card
|
||||||
|
- expect: No "Due by" sub-text (extended = no deadline for remainder of day)
|
||||||
|
|
||||||
|
#### 8.5. Extending the same chore twice returns 409
|
||||||
|
|
||||||
|
- After the first extension, try opening kebab again
|
||||||
|
- expect: "Extend Time" is no longer in the menu (chore is no longer expired)
|
||||||
|
|
||||||
|
#### 8.6. Extension effect is visible in child mode
|
||||||
|
|
||||||
|
- After extending the chore in parent mode
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore is visible and NOT showing "TOO LATE" badge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Schedule with Interval Mode — Anchor Date & Next Occurrence
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/interval-anchor.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `IntervalAnchorChild` and chore `IntervalAnchorChore`. Assigns chore. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 9.1. Interval schedule set to every 1 day from today — chore active today
|
||||||
|
|
||||||
|
- Create interval schedule: `interval_days=1`, `anchor_date=today`, deadline 23:59
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Chore is NOT grayed out (hits today)
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore IS visible
|
||||||
|
|
||||||
|
#### 9.2. Interval schedule set to every 2 days from yesterday — chore NOT active today
|
||||||
|
|
||||||
|
- Create interval schedule: `interval_days=2`, `anchor_date=yesterday`
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Chore IS grayed out (yesterday + 2 = tomorrow, not today)
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore is NOT visible
|
||||||
|
|
||||||
|
#### 9.3. Interval schedule set to every 2 days starting today — active today
|
||||||
|
|
||||||
|
- Create interval schedule: `interval_days=2`, `anchor_date=today`
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Chore is NOT grayed out
|
||||||
|
- Navigate to `/child/:childId`
|
||||||
|
- expect: Chore IS visible
|
||||||
|
|
||||||
|
#### 9.4. Interval "Anytime" deadline — no "Due by" label
|
||||||
|
|
||||||
|
- Create interval schedule with `interval_has_deadline=false`
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: No "Due by" sub-text on the chore card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. SSE Real-Time Updates
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/schedule-sse.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child `SSEScheduleChild` and chore `SSEScheduleChore`. Assigns chore. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 10.1. Setting a schedule via API reflects in an already-open parent view
|
||||||
|
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: Chore is visible, no schedule annotations
|
||||||
|
- Via API call (`request.put`), create a schedule for a non-today weekday
|
||||||
|
- expect: Within a few seconds, the chore card becomes grayed out (SSE `chore_schedule_modified` fires, list refreshes)
|
||||||
|
|
||||||
|
#### 10.2. Deleting a schedule via API un-grays the chore in parent view
|
||||||
|
|
||||||
|
- (Continuing from 10.1) Delete the schedule via API
|
||||||
|
- expect: Within a few seconds, the chore card returns to normal styling
|
||||||
|
|
||||||
|
#### 10.3. Extending time via API reflects in an already-open parent view
|
||||||
|
|
||||||
|
- Set up a schedule with a past deadline (00:01) via API so chore is expired
|
||||||
|
- Navigate to `/parent/:childId`
|
||||||
|
- expect: "TOO LATE" badge visible
|
||||||
|
- Extend time via API call
|
||||||
|
- expect: "TOO LATE" badge disappears (SSE `chore_time_extended` fires, list refreshes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Schedule Loading — Backward Compatibility
|
||||||
|
|
||||||
|
**File:** `e2e/mode_parent/chore-scheduler/schedule-load.spec.ts`
|
||||||
|
|
||||||
|
**Seed:** Creates child and chore. Uses direct API calls to create schedules with specific field combinations. Deletes all in `afterAll`.
|
||||||
|
|
||||||
|
#### 11.1. Schedule with `enabled` field missing defaults to enabled
|
||||||
|
|
||||||
|
- Create schedule via API without `enabled` field
|
||||||
|
- Open Schedule modal
|
||||||
|
- expect: Toggle shows "Enabled" (defaults to true)
|
||||||
|
|
||||||
|
#### 11.2. Schedule with `interval_has_deadline=false` shows "Anytime"
|
||||||
|
|
||||||
|
- Create interval schedule with `interval_has_deadline=false`
|
||||||
|
- Open Schedule modal
|
||||||
|
- expect: "Anytime" label is shown instead of time picker
|
||||||
|
|
||||||
|
#### 11.3. Existing days schedule with mixed times loads correctly
|
||||||
|
|
||||||
|
- Create days schedule with 3 days: Mon (08:00), Wed (08:00), Fri (10:30) — Fri is an exception
|
||||||
|
- Open Schedule modal
|
||||||
|
- expect: Mo, We, Fr chips are active
|
||||||
|
- expect: Default deadline shows 08:00 AM
|
||||||
|
- expect: Friday row shows its own time picker with 10:30 AM
|
||||||
|
|
||||||
|
#### 11.4. Interval schedule loads anchor date and interval correctly
|
||||||
|
|
||||||
|
- Create interval schedule via API with `anchor_date="2026-03-15"`, `interval_days=3`
|
||||||
|
- Open Schedule modal
|
||||||
|
- expect: "Every X Days" mode is active
|
||||||
|
- expect: Stepper shows 3
|
||||||
|
- expect: Date field shows 2026-03-15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Spec References
|
||||||
|
|
||||||
|
- `.chore-inactive`: `opacity: 0.45; filter: grayscale(60%)` — applied in both parent and child views
|
||||||
|
- `.chore-stamp`: absolute-positioned badge overlay for "TOO LATE", "COMPLETED", "PENDING"
|
||||||
|
- `.due-label`: "Due by X:XX PM" sub-text below point value
|
||||||
|
- `.schedule-body.disabled`: `opacity: 0.45; pointer-events: none` — dim wrapper when schedule is paused
|
||||||
|
- `.toggle-track.on`: `background: var(--btn-primary)` — toggle ON state
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
import { STORAGE_STATE, STORAGE_STATE_NO_PIN } from './e2e/e2e-constants'
|
import {
|
||||||
|
STORAGE_STATE,
|
||||||
|
STORAGE_STATE_NO_PIN,
|
||||||
|
STORAGE_STATE_DELETE,
|
||||||
|
STORAGE_STATE_CC,
|
||||||
|
} from './e2e/e2e-constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -38,6 +43,9 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||||
{ name: 'setup-no-pin', testMatch: /auth-no-pin\.setup\.ts/ },
|
{ name: 'setup-no-pin', testMatch: /auth-no-pin\.setup\.ts/ },
|
||||||
|
// Depends on 'setup' because e2e-seed (run in setup) truncates all users first.
|
||||||
|
{ name: 'setup-delete', testMatch: /auth-delete\.setup\.ts/, dependencies: ['setup'] },
|
||||||
|
{ name: 'setup-cc', testMatch: /auth-cc\.setup\.ts/, dependencies: ['setup'] },
|
||||||
|
|
||||||
{
|
{
|
||||||
// Bucket A: child-options tests — run before create-child so that
|
// Bucket A: child-options tests — run before create-child so that
|
||||||
@@ -50,12 +58,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
// Bucket B: create-child tests — depends on chromium-child-options so it
|
// Bucket B: create-child tests — uses its own isolated user (e2e-cc@test.com)
|
||||||
// starts only after all child-options tests have finished and cleaned up.
|
// so deleteAllChildren() only affects that user's children and never interferes
|
||||||
// This guarantees deleteAllChildren() runs in a quiet window.
|
// with concurrent buckets using the main E2E user.
|
||||||
name: 'chromium-create-child',
|
name: 'chromium-create-child',
|
||||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_CC },
|
||||||
dependencies: ['setup', 'chromium-child-options'],
|
dependencies: ['setup-cc'],
|
||||||
testMatch: [/mode_parent\/create-child\/.+\.spec\.ts/],
|
testMatch: [/mode_parent\/create-child\/.+\.spec\.ts/],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -64,6 +72,8 @@ export default defineConfig({
|
|||||||
// fullyParallel:false prevents files from being split across workers;
|
// fullyParallel:false prevents files from being split across workers;
|
||||||
// the merged kindness-default and penalty-default files use mode:'serial'
|
// the merged kindness-default and penalty-default files use mode:'serial'
|
||||||
// internally to guarantee the convert and delete tests never run in parallel.
|
// internally to guarantee the convert and delete tests never run in parallel.
|
||||||
|
// API-based beforeEach cleanup in each spec makes these tests resilient to
|
||||||
|
// concurrent SSE activity from other buckets.
|
||||||
name: 'chromium-default-tasks',
|
name: 'chromium-default-tasks',
|
||||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
@@ -98,6 +108,14 @@ export default defineConfig({
|
|||||||
testMatch: [/mode_parent\/task-modification\/.+\.spec\.ts/],
|
testMatch: [/mode_parent\/task-modification\/.+\.spec\.ts/],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// Bucket: chore scheduler tests — each spec creates isolated child+chores.
|
||||||
|
name: 'chromium-chore-scheduler',
|
||||||
|
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
testMatch: [/mode_parent\/chore-scheduler\/.+\.spec\.ts/],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
// Bucket: parent profile button tests (permanent parent mode).
|
// Bucket: parent profile button tests (permanent parent mode).
|
||||||
name: 'chromium-profile-button',
|
name: 'chromium-profile-button',
|
||||||
@@ -123,10 +141,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
// Bucket: delete account tests — MUST run last; re-seeds the DB in afterAll.
|
// Bucket: delete account tests — uses its own isolated user (e2e-delete@test.com)
|
||||||
|
// so the shared E2E session is never affected and no cross-project dependencies needed.
|
||||||
name: 'chromium-user-profile-delete',
|
name: 'chromium-user-profile-delete',
|
||||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_DELETE },
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup-delete'],
|
||||||
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
|
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -143,6 +162,7 @@ export default defineConfig({
|
|||||||
/mode_parent\/task-modification\//,
|
/mode_parent\/task-modification\//,
|
||||||
/mode_parent\/profile-button\//,
|
/mode_parent\/profile-button\//,
|
||||||
/mode_parent\/user-profile\//,
|
/mode_parent\/user-profile\//,
|
||||||
|
/mode_parent\/chore-scheduler\//,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,64 @@ describe('isScheduledToday', () => {
|
|||||||
const thursday = new Date(2025, 0, 9)
|
const thursday = new Date(2025, 0, 9)
|
||||||
expect(isScheduledToday(intervalSchedule, thursday)).toBe(false)
|
expect(isScheduledToday(intervalSchedule, thursday)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns true when enabled is false on a scheduled day (days mode) — paused = always shown', () => {
|
||||||
|
const pausedSchedule: ChoreSchedule = {
|
||||||
|
...daysSchedule,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
const monday = new Date(2025, 0, 6) // Monday — scheduled day
|
||||||
|
expect(isScheduledToday(pausedSchedule, monday)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when enabled is false on an unscheduled day (days mode) — paused = always shown', () => {
|
||||||
|
const pausedSchedule: ChoreSchedule = {
|
||||||
|
...daysSchedule,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
const tuesday = new Date(2025, 0, 7) // Tuesday — NOT a scheduled day normally
|
||||||
|
expect(isScheduledToday(pausedSchedule, tuesday)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when enabled is false on a hit day (interval mode) — paused = always shown', () => {
|
||||||
|
const pausedInterval: ChoreSchedule = {
|
||||||
|
child_id: 'c1',
|
||||||
|
task_id: 't1',
|
||||||
|
mode: 'interval',
|
||||||
|
day_configs: [],
|
||||||
|
interval_days: 2,
|
||||||
|
anchor_date: '2025-01-08',
|
||||||
|
interval_has_deadline: true,
|
||||||
|
interval_hour: 8,
|
||||||
|
interval_minute: 0,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
const wednesday = new Date(2025, 0, 8) // would normally hit
|
||||||
|
expect(isScheduledToday(pausedInterval, wednesday)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when enabled is false on a non-hit day (interval mode) — paused = always shown', () => {
|
||||||
|
const pausedInterval: ChoreSchedule = {
|
||||||
|
child_id: 'c1',
|
||||||
|
task_id: 't1',
|
||||||
|
mode: 'interval',
|
||||||
|
day_configs: [],
|
||||||
|
interval_days: 2,
|
||||||
|
anchor_date: '2025-01-08',
|
||||||
|
interval_has_deadline: true,
|
||||||
|
interval_hour: 8,
|
||||||
|
interval_minute: 0,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
const thursday = new Date(2025, 0, 9) // would NOT normally hit
|
||||||
|
expect(isScheduledToday(pausedInterval, thursday)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats enabled=undefined as enabled (backward compat)', () => {
|
||||||
|
// daysSchedule has no enabled field — should behave as enabled
|
||||||
|
const monday = new Date(2025, 0, 6)
|
||||||
|
expect(isScheduledToday(daysSchedule, monday)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,6 +243,25 @@ describe('getDueTimeToday', () => {
|
|||||||
// Even on a hit day, Anytime means no deadline → null
|
// Even on a hit day, Anytime means no deadline → null
|
||||||
expect(getDueTimeToday(intervalSchedule, wednesday)).toBeNull()
|
expect(getDueTimeToday(intervalSchedule, wednesday)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns null when schedule is paused (enabled=false) — paused = no deadline', () => {
|
||||||
|
const pausedSchedule: ChoreSchedule = {
|
||||||
|
child_id: 'c1',
|
||||||
|
task_id: 't1',
|
||||||
|
mode: 'days',
|
||||||
|
day_configs: [
|
||||||
|
{ day: 1, has_deadline: true, hour: 8, minute: 30 }, // Monday with deadline
|
||||||
|
],
|
||||||
|
interval_days: null,
|
||||||
|
anchor_date: null,
|
||||||
|
interval_has_deadline: false,
|
||||||
|
interval_hour: null,
|
||||||
|
interval_minute: null,
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
const monday = new Date(2025, 0, 6) // Monday — would normally return { hour: 8, minute: 30 }
|
||||||
|
expect(getDueTimeToday(pausedSchedule, monday)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface ChoreSchedule {
|
|||||||
interval_has_deadline: boolean // false = "Anytime" (no deadline)
|
interval_has_deadline: boolean // false = "Anytime" (no deadline)
|
||||||
interval_hour: number
|
interval_hour: number
|
||||||
interval_minute: number
|
interval_minute: number
|
||||||
|
enabled?: boolean // false = schedule paused; chore won't appear for child
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export function intervalHitsToday(
|
|||||||
* Returns true if the schedule applies today (localDate).
|
* Returns true if the schedule applies today (localDate).
|
||||||
* - 'days' mode: today's weekday is in day_configs
|
* - 'days' mode: today's weekday is in day_configs
|
||||||
* - 'interval' mode: intervalHitsToday
|
* - 'interval' mode: intervalHitsToday
|
||||||
|
* - paused (enabled === false): always returns true (chore shows every day, like no schedule)
|
||||||
*/
|
*/
|
||||||
export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean {
|
export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean {
|
||||||
|
if (schedule.enabled === false) return true // paused = show always, like an unscheduled chore
|
||||||
if (schedule.mode === 'days') {
|
if (schedule.mode === 'days') {
|
||||||
const todayWeekday = getLocalWeekday(localDate)
|
const todayWeekday = getLocalWeekday(localDate)
|
||||||
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
|
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
|
||||||
@@ -56,6 +58,7 @@ export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): bool
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the due time {hour, minute} for today, or null if:
|
* Returns the due time {hour, minute} for today, or null if:
|
||||||
|
* - the schedule is paused (enabled === false) → no deadline, acts like Anytime
|
||||||
* - the day is not scheduled, OR
|
* - the day is not scheduled, OR
|
||||||
* - the schedule has no deadline (interval_has_deadline === false → "Anytime")
|
* - the schedule has no deadline (interval_has_deadline === false → "Anytime")
|
||||||
*
|
*
|
||||||
@@ -65,6 +68,7 @@ export function getDueTimeToday(
|
|||||||
schedule: ChoreSchedule,
|
schedule: ChoreSchedule,
|
||||||
localDate: Date,
|
localDate: Date,
|
||||||
): { hour: number; minute: number } | null {
|
): { hour: number; minute: number } | null {
|
||||||
|
if (schedule.enabled === false) return null // paused = no deadline, no expiry
|
||||||
if (schedule.mode === 'days') {
|
if (schedule.mode === 'days') {
|
||||||
// default_has_deadline === false means 'Anytime' — no expiry for scheduled days
|
// default_has_deadline === false means 'Anytime' — no expiry for scheduled days
|
||||||
if (schedule.default_has_deadline === false) return null
|
if (schedule.default_has_deadline === false) return null
|
||||||
|
|||||||
@@ -1,135 +1,154 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
|
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
|
||||||
<!-- Mode toggle -->
|
<!-- Enable/disable toggle row -->
|
||||||
<div class="mode-toggle">
|
<div class="schedule-toggle-row">
|
||||||
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
|
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
|
||||||
Specific Days
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
|
class="toggle-track"
|
||||||
Every X Days
|
:class="{ on: scheduleEnabled }"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="scheduleEnabled"
|
||||||
|
@click="scheduleEnabled = !scheduleEnabled"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Specific Days form -->
|
<!-- Form body dims when paused -->
|
||||||
<div v-if="mode === 'days'" class="days-form">
|
<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">
|
||||||
<!-- Day chips -->
|
<!-- Mode toggle -->
|
||||||
<div class="day-chips">
|
<div class="mode-toggle">
|
||||||
<button
|
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
|
||||||
v-for="(label, idx) in DAY_CHIP_LABELS"
|
Specific Days
|
||||||
:key="idx"
|
</button>
|
||||||
type="button"
|
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
|
||||||
:class="['chip', { active: selectedDays.has(idx) }]"
|
Every X Days
|
||||||
@click="toggleDay(idx)"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default deadline -->
|
<!-- Specific Days form -->
|
||||||
<div v-if="selectedDays.size > 0" class="default-deadline-row">
|
<div v-if="mode === 'days'" class="days-form">
|
||||||
<span class="field-label">Deadline</span>
|
<!-- Day chips -->
|
||||||
<TimePickerPopover
|
<div class="day-chips">
|
||||||
v-if="hasDefaultDeadline"
|
<button
|
||||||
:modelValue="defaultTime"
|
v-for="(label, idx) in DAY_CHIP_LABELS"
|
||||||
@update:modelValue="onDefaultTimeChanged"
|
:key="idx"
|
||||||
/>
|
type="button"
|
||||||
<span v-else class="anytime-label">Anytime</span>
|
:class="['chip', { active: selectedDays.has(idx) }]"
|
||||||
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
|
@click="toggleDay(idx)"
|
||||||
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
>
|
||||||
</button>
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default deadline -->
|
||||||
|
<div v-if="selectedDays.size > 0" class="default-deadline-row">
|
||||||
|
<span class="field-label">Deadline</span>
|
||||||
|
<TimePickerPopover
|
||||||
|
v-if="hasDefaultDeadline"
|
||||||
|
:modelValue="defaultTime"
|
||||||
|
@update:modelValue="onDefaultTimeChanged"
|
||||||
|
/>
|
||||||
|
<span v-else class="anytime-label">Anytime</span>
|
||||||
|
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
|
||||||
|
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected day exception list -->
|
||||||
|
<div v-if="selectedDays.size > 0" class="exception-list">
|
||||||
|
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
||||||
|
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||||||
|
<div class="exception-right">
|
||||||
|
<template v-if="exceptions.has(idx)">
|
||||||
|
<TimePickerPopover
|
||||||
|
:modelValue="exceptions.get(idx)!"
|
||||||
|
@update:modelValue="(val) => exceptions.set(idx, val)"
|
||||||
|
/>
|
||||||
|
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="default-label">{{
|
||||||
|
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
|
||||||
|
}}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-btn"
|
||||||
|
@click="exceptions.set(idx, { ...defaultTime })"
|
||||||
|
>
|
||||||
|
Set different time
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected day exception list -->
|
<!-- Interval form -->
|
||||||
<div v-if="selectedDays.size > 0" class="exception-list">
|
<div v-else class="interval-form">
|
||||||
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
<!-- Frequency stepper + anchor date -->
|
||||||
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
<div class="interval-group">
|
||||||
<div class="exception-right">
|
<div class="interval-row">
|
||||||
<template v-if="exceptions.has(idx)">
|
<label class="field-label">Every</label>
|
||||||
<TimePickerPopover
|
<div class="stepper">
|
||||||
:modelValue="exceptions.get(idx)!"
|
|
||||||
@update:modelValue="(val) => exceptions.set(idx, val)"
|
|
||||||
/>
|
|
||||||
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
|
|
||||||
Reset to default
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="default-label">{{
|
|
||||||
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
|
|
||||||
}}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="link-btn"
|
class="stepper-btn"
|
||||||
@click="exceptions.set(idx, { ...defaultTime })"
|
@click="decrementInterval"
|
||||||
|
:disabled="intervalDays <= 1"
|
||||||
>
|
>
|
||||||
Set different time
|
−
|
||||||
</button>
|
</button>
|
||||||
</template>
|
<span class="stepper-value">{{ intervalDays }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stepper-btn"
|
||||||
|
@click="incrementInterval"
|
||||||
|
:disabled="intervalDays >= 7"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="interval-row">
|
||||||
|
<label class="field-label">Starting on</label>
|
||||||
|
<DateInputField
|
||||||
|
:modelValue="anchorDate"
|
||||||
|
:min="todayISO"
|
||||||
|
@update:modelValue="anchorDate = $event"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Interval form -->
|
<!-- Next occurrence preview -->
|
||||||
<div v-else class="interval-form">
|
<div v-if="nextOccurrence" class="next-occurrence-row">
|
||||||
<!-- Frequency stepper + anchor date -->
|
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
|
||||||
<div class="interval-group">
|
|
||||||
<div class="interval-row">
|
|
||||||
<label class="field-label">Every</label>
|
|
||||||
<div class="stepper">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="stepper-btn"
|
|
||||||
@click="decrementInterval"
|
|
||||||
:disabled="intervalDays <= 1"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<span class="stepper-value">{{ intervalDays }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="stepper-btn"
|
|
||||||
@click="incrementInterval"
|
|
||||||
:disabled="intervalDays >= 7"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="interval-row">
|
|
||||||
<label class="field-label">Starting on</label>
|
<!-- Deadline row -->
|
||||||
<DateInputField
|
<div class="interval-time-row">
|
||||||
:modelValue="anchorDate"
|
<label class="field-label">Deadline</label>
|
||||||
:min="todayISO"
|
<TimePickerPopover
|
||||||
@update:modelValue="anchorDate = $event"
|
v-if="hasDeadline"
|
||||||
|
:modelValue="intervalTime"
|
||||||
|
@update:modelValue="intervalTime = $event"
|
||||||
/>
|
/>
|
||||||
|
<span v-else class="anytime-label">Anytime</span>
|
||||||
|
<button type="button" class="link-btn" @click="toggleDeadline">
|
||||||
|
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Next occurrence preview -->
|
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||||
<div v-if="nextOccurrence" class="next-occurrence-row">
|
|
||||||
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Deadline row -->
|
|
||||||
<div class="interval-time-row">
|
|
||||||
<label class="field-label">Deadline</label>
|
|
||||||
<TimePickerPopover
|
|
||||||
v-if="hasDeadline"
|
|
||||||
:modelValue="intervalTime"
|
|
||||||
@update:modelValue="intervalTime = $event"
|
|
||||||
/>
|
|
||||||
<span v-else class="anytime-label">Anytime</span>
|
|
||||||
<button type="button" class="link-btn" @click="toggleDeadline">
|
|
||||||
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- /schedule-body -->
|
||||||
|
|
||||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
<!-- Actions outside dim wrapper so they stay interactive -->
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
|
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -201,6 +220,7 @@ const selectedDays = ref<Set<number>>(_days)
|
|||||||
const defaultTime = ref<TimeValue>(_base)
|
const defaultTime = ref<TimeValue>(_base)
|
||||||
const exceptions = ref<Map<number, TimeValue>>(_exMap)
|
const exceptions = ref<Map<number, TimeValue>>(_exMap)
|
||||||
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
|
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
|
||||||
|
const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)
|
||||||
|
|
||||||
// ── helpers (date) ───────────────────────────────────────────────────────────
|
// ── helpers (date) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -237,6 +257,7 @@ const origIntervalTime: TimeValue = {
|
|||||||
hour: props.schedule?.interval_hour ?? 8,
|
hour: props.schedule?.interval_hour ?? 8,
|
||||||
minute: props.schedule?.interval_minute ?? 0,
|
minute: props.schedule?.interval_minute ?? 0,
|
||||||
}
|
}
|
||||||
|
const origEnabled = props.schedule?.enabled ?? true
|
||||||
|
|
||||||
// ── computed ─────────────────────────────────────────────────────────────────
|
// ── computed ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -320,6 +341,7 @@ const isValid = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isDirty = computed(() => {
|
const isDirty = computed(() => {
|
||||||
|
if (scheduleEnabled.value !== origEnabled) return true
|
||||||
if (mode.value !== origMode) return true
|
if (mode.value !== origMode) return true
|
||||||
|
|
||||||
if (mode.value === 'days') {
|
if (mode.value === 'days') {
|
||||||
@@ -379,6 +401,7 @@ async function save() {
|
|||||||
})
|
})
|
||||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||||
mode: 'days',
|
mode: 'days',
|
||||||
|
enabled: scheduleEnabled.value,
|
||||||
day_configs,
|
day_configs,
|
||||||
default_hour: defaultTime.value.hour,
|
default_hour: defaultTime.value.hour,
|
||||||
default_minute: defaultTime.value.minute,
|
default_minute: defaultTime.value.minute,
|
||||||
@@ -387,6 +410,7 @@ async function save() {
|
|||||||
} else {
|
} else {
|
||||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||||
mode: 'interval',
|
mode: 'interval',
|
||||||
|
enabled: scheduleEnabled.value,
|
||||||
interval_days: intervalDays.value,
|
interval_days: intervalDays.value,
|
||||||
anchor_date: anchorDate.value,
|
anchor_date: anchorDate.value,
|
||||||
interval_has_deadline: hasDeadline.value,
|
interval_has_deadline: hasDeadline.value,
|
||||||
@@ -660,4 +684,66 @@ async function save() {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Enable/Disable Toggle ────────────────────── */
|
||||||
|
|
||||||
|
.schedule-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--form-label, #444);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: var(--form-input-border, #cbd5e1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track.on {
|
||||||
|
background: var(--btn-primary, #667eea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track.on .toggle-thumb {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dim wrapper when paused ──────────────────── */
|
||||||
|
|
||||||
|
.schedule-body {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-body.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
BIN
notes/scheduler_enable_disable/image-1.png
Normal file
BIN
notes/scheduler_enable_disable/image-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
notes/scheduler_enable_disable/image-2.png
Normal file
BIN
notes/scheduler_enable_disable/image-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
notes/scheduler_enable_disable/image.png
Normal file
BIN
notes/scheduler_enable_disable/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
34
notes/scheduler_enable_disable/scheduler_enable_disable.md
Normal file
34
notes/scheduler_enable_disable/scheduler_enable_disable.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
## UI Design Proposal
|
||||||
|
|
||||||
|
I want the chore scheduler to have a feature to enable and disable a chore. If the user wants to stop a chore from being scheduled on certain days, the chore will be disabled. Otherwise it will be enabled
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
frontend\vue-app\src\components\shared\ScheduleModal.vue
|
||||||
|
|
||||||
|
## Ideas
|
||||||
|
|
||||||
|
I propose a toggle button that looks like the following:
|
||||||
|

|
||||||
|
This slider could go into the red rectangles in the dialogs below.
|
||||||
|
Since there are 2 types of schedulers, we will have this toggle on both.
|
||||||
|
For mobile the toggles should be 44px in dimension.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
Does near the top of the dialog work well for UI/UX, or should the toggle be somewhere else? Or should the dialogs be reworked somehow?
|
||||||
|
|
||||||
|
If we go with toggle, instead of green, should we use a color that works with our current palette? (blue)
|
||||||
|
|
||||||
|
## Every X Days
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Specific Days
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### E2E Tests — `frontend/vue-app/tests/`
|
||||||
|
|
||||||
|
- [ ] **toggle persists across modal reopen**: Open schedule modal, toggle to paused, save; reopen the same modal, verify toggle is still in paused state
|
||||||
|
- [ ] **paused schedule does not show chore for child**: Pause a schedule, switch to child view, verify the chore does not appear as active
|
||||||
Reference in New Issue
Block a user