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

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