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

- 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:
2026-03-20 16:42:13 -04:00
parent db6e0a7ce8
commit ef9cb01d92
45 changed files with 3543 additions and 232 deletions

73
.github/agents/ui-planner.agent.md vendored Normal file
View 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).

View 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
View File

@@ -23,6 +23,10 @@
"chat.tools.terminal.autoApprove": {
"&": true
},
"terminal.integrated.automationProfile.windows": {
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"args": []
},
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.pythonProjects": [],
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
@@ -34,18 +38,4 @@
"editor.fontFamily": "JetBrains Mono",
"editor.fontSize": 13,
"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
View File

@@ -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",
"isBackground": false,
"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"
}
}
]
}

View File

@@ -43,6 +43,12 @@ ACCESS_TOKEN_EXPIRY_MINUTES = 15
E2E_TEST_EMAIL = 'e2e@test.com'
E2E_TEST_PASSWORD = 'E2eTestPass1!'
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):
@@ -470,6 +476,52 @@ def logout():
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'])
def e2e_seed():
"""Reset the database and insert a verified test user. Only available outside production."""

View File

@@ -54,6 +54,10 @@ def set_chore_schedule(child_id, task_id):
if mode not in ('days', 'interval'):
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':
day_configs = data.get('day_configs', [])
if not isinstance(day_configs, list):
@@ -69,6 +73,7 @@ def set_chore_schedule(child_id, task_id):
default_hour=default_hour,
default_minute=default_minute,
default_has_deadline=default_has_deadline,
enabled=enabled,
)
else:
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_hour=interval_hour,
interval_minute=interval_minute,
enabled=enabled,
)
delete_extension_for_child_task(child_id, task_id)

View File

@@ -64,7 +64,7 @@ def sse_response_for_user(user_id: str):
# This prevents Werkzeug's dev server from starving other connections.
message = user_queue.get(timeout=15)
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:
# Send an SSE comment as a keepalive ping to maintain the connection.
yield b': ping\n\n'

View File

@@ -35,6 +35,9 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
logging.getLogger("werkzeug").setLevel(logging.WARNING)
logging.getLogger("events.sse").setLevel(logging.WARNING)
app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
#Todo - add prefix to all these routes instead of in each blueprint

View File

@@ -44,6 +44,8 @@ class ChoreSchedule(BaseModel):
interval_hour: int = 0
interval_minute: int = 0
enabled: bool = True # False = schedule paused; chore won't appear for child
@classmethod
def from_dict(cls, d: dict) -> 'ChoreSchedule':
return cls(
@@ -59,6 +61,7 @@ class ChoreSchedule(BaseModel):
interval_has_deadline=d.get('interval_has_deadline', True),
interval_hour=d.get('interval_hour', 0),
interval_minute=d.get('interval_minute', 0),
enabled=d.get('enabled', True),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
@@ -79,5 +82,6 @@ class ChoreSchedule(BaseModel):
'interval_has_deadline': self.interval_has_deadline,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
'enabled': self.enabled,
})
return base

View File

@@ -319,3 +319,96 @@ def test_extend_chore_time_missing_date(client):
def test_extend_chore_time_bad_child(client):
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
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

View File

@@ -1,5 +1,6 @@
from flask import current_app
from flask_mail import Mail, Message
import os
def send_verification_email(to_email: str, token: str) -> None:
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')
)
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)
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
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')
)
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)
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
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')
)
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)
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
except Exception:

View 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}"
}
]
}
]
}

View 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}"
}
]
}
]
}

View File

@@ -2,17 +2,17 @@
"cookies": [
{
"name": "refresh_token",
"value": "zSbcFMeA59rQCWIkZEDsLO54zjpNu1M0mH0AsHq0Unc",
"value": "YAXw1Kh8dLOuSYc1ZW_sgYHWRzo0XivYXmAHAr2RV-o",
"domain": "localhost",
"path": "/api/auth",
"expires": 1781644571.72276,
"expires": 1781792619.603786,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjMDFmNWJhYS1kMGQ3LTQwYzctOTE2OC1mZGY0M2FiNTVmMzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4Njk0NzF9._LZhytX_HqOpeFQ8XGlHqTz6efhIUDZa2Dp7SB-acqE",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzMzAwYjVjNy0yMzIxLTQxMWMtOWZiZC03NzY4ZjM4NTI1OWYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQwMTc1MTl9.2XDC_S2IuKeF_S9HTeHEOCWlUAtlrGpByLdIwrtoxfw",
"domain": "localhost",
"path": "/",
"expires": -1,
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1773868571358}"
"value": "{\"type\":\"logout\",\"at\":1774016619428}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1774041371935}"
"value": "{\"expiresAt\":1774189419776}"
}
]
}

View 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('46 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 })
})

View 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('46 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 })
})

View File

@@ -1,7 +1,15 @@
export const STORAGE_STATE = 'e2e/.auth/user.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_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_PASSWORD = 'E2eTestPass1!'
export const E2E_PIN = '1234'
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'

View File

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

View File

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

View 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 })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// spec: e2e/plans/create-child.plan.md
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.configure({ mode: 'serial' })
@@ -68,7 +68,7 @@ test.describe('Create Child', () => {
await expect(page).toHaveURL('/parent')
// 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()
// Load the app first (to ensure localStorage is seeded from storageState),

View File

@@ -2,11 +2,32 @@
// 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.
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.configure({ mode: 'serial' })
test.beforeEach(async ({ request }) => {
await cleanupBeGoodTasks(request)
})
test('Convert a default kindness act to a user item by editing', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await page.click('text=Kindness Acts')
@@ -45,23 +66,6 @@ test.describe('Default kindness act management', () => {
await page.goto('/parent/tasks/chores')
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)
await expect(page.getByText('Be good for the day', { exact: true })).toBeVisible()
await expect(

View File

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

View File

@@ -2,32 +2,33 @@
// Merged from penalty-convert-default.spec.ts and penalty-delete-default.spec.ts.
// 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.configure({ mode: 'serial' })
test.beforeEach(async ({ request }) => {
await cleanupFightingTasks(request)
})
test('Convert a default penalty to a user item by editing', async ({ page }) => {
await page.goto('/parent/tasks/chores')
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
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
await expect(
@@ -67,23 +68,6 @@ test.describe('Default penalty management', () => {
await page.goto('/parent/tasks/chores')
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)
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
await expect(

View File

@@ -1,18 +1,11 @@
// spec: e2e/plans/user-profile.plan.md
import { test, expect } from '@playwright/test'
import { E2E_EMAIL, E2E_PASSWORD } from '../../e2e-constants'
const BACKEND = 'http://localhost:5000'
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
test.describe('User Profile Delete Account', () => {
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 }) => {
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 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(
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 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()
// 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 }) => {
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_EMAIL)
await page.getByLabel('Password').fill(E2E_PASSWORD)
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()
// Login must fail with a deletion-related error message

View File

@@ -57,7 +57,10 @@ test.describe('User Profile editing', () => {
test('Back button navigates back', async ({ page }) => {
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()

View 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 17
- 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

View File

@@ -1,5 +1,10 @@
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.
@@ -38,6 +43,9 @@ export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.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
@@ -50,12 +58,12 @@ export default defineConfig({
},
{
// Bucket B: create-child tests — depends on chromium-child-options so it
// starts only after all child-options tests have finished and cleaned up.
// This guarantees deleteAllChildren() runs in a quiet window.
// Bucket B: create-child tests — uses its own isolated user (e2e-cc@test.com)
// so deleteAllChildren() only affects that user's children and never interferes
// with concurrent buckets using the main E2E user.
name: 'chromium-create-child',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup', 'chromium-child-options'],
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_CC },
dependencies: ['setup-cc'],
testMatch: [/mode_parent\/create-child\/.+\.spec\.ts/],
},
@@ -64,6 +72,8 @@ export default defineConfig({
// fullyParallel:false prevents files from being split across workers;
// the merged kindness-default and penalty-default files use mode:'serial'
// 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',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
@@ -98,6 +108,14 @@ export default defineConfig({
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).
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',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_DELETE },
dependencies: ['setup-delete'],
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
},
@@ -143,6 +162,7 @@ export default defineConfig({
/mode_parent\/task-modification\//,
/mode_parent\/profile-button\//,
/mode_parent\/user-profile\//,
/mode_parent\/chore-scheduler\//,
],
},

View File

@@ -109,6 +109,64 @@ describe('isScheduledToday', () => {
const thursday = new Date(2025, 0, 9)
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
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()
})
})
// ---------------------------------------------------------------------------

View File

@@ -32,6 +32,7 @@ export interface ChoreSchedule {
interval_has_deadline: boolean // false = "Anytime" (no deadline)
interval_hour: number
interval_minute: number
enabled?: boolean // false = schedule paused; chore won't appear for child
created_at: number
updated_at: number
}

View File

@@ -44,8 +44,10 @@ export function intervalHitsToday(
* Returns true if the schedule applies today (localDate).
* - 'days' mode: today's weekday is in day_configs
* - 'interval' mode: intervalHitsToday
* - paused (enabled === false): always returns true (chore shows every day, like no schedule)
*/
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') {
const todayWeekday = getLocalWeekday(localDate)
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:
* - the schedule is paused (enabled === false) → no deadline, acts like Anytime
* - the day is not scheduled, OR
* - the schedule has no deadline (interval_has_deadline === false → "Anytime")
*
@@ -65,6 +68,7 @@ export function getDueTimeToday(
schedule: ChoreSchedule,
localDate: Date,
): { hour: number; minute: number } | null {
if (schedule.enabled === false) return null // paused = no deadline, no expiry
if (schedule.mode === 'days') {
// default_has_deadline === false means 'Anytime' — no expiry for scheduled days
if (schedule.default_has_deadline === false) return null

View File

@@ -1,5 +1,22 @@
<template>
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
<!-- Enable/disable toggle row -->
<div class="schedule-toggle-row">
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
<button
type="button"
class="toggle-track"
:class="{ on: scheduleEnabled }"
role="switch"
:aria-checked="scheduleEnabled"
@click="scheduleEnabled = !scheduleEnabled"
>
<span class="toggle-thumb" />
</button>
</div>
<!-- Form body dims when paused -->
<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">
<!-- Mode toggle -->
<div class="mode-toggle">
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
@@ -128,8 +145,10 @@
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
<!-- /schedule-body -->
<!-- Actions -->
<!-- Actions outside dim wrapper so they stay interactive -->
<div class="modal-actions">
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
Cancel
@@ -201,6 +220,7 @@ const selectedDays = ref<Set<number>>(_days)
const defaultTime = ref<TimeValue>(_base)
const exceptions = ref<Map<number, TimeValue>>(_exMap)
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)
// ── helpers (date) ───────────────────────────────────────────────────────────
@@ -237,6 +257,7 @@ const origIntervalTime: TimeValue = {
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
}
const origEnabled = props.schedule?.enabled ?? true
// ── computed ─────────────────────────────────────────────────────────────────
@@ -320,6 +341,7 @@ const isValid = computed(() => {
})
const isDirty = computed(() => {
if (scheduleEnabled.value !== origEnabled) return true
if (mode.value !== origMode) return true
if (mode.value === 'days') {
@@ -379,6 +401,7 @@ async function save() {
})
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'days',
enabled: scheduleEnabled.value,
day_configs,
default_hour: defaultTime.value.hour,
default_minute: defaultTime.value.minute,
@@ -387,6 +410,7 @@ async function save() {
} else {
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'interval',
enabled: scheduleEnabled.value,
interval_days: intervalDays.value,
anchor_date: anchorDate.value,
interval_has_deadline: hasDeadline.value,
@@ -660,4 +684,66 @@ async function save() {
opacity: 0.5;
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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View 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:
![alt text](image.png)
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
![alt text](image-1.png)
## Specific Days
![alt text](image-2.png)
### 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