feat: add enable/disable toggle for chore scheduling in ScheduleModal
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
- Introduced a toggle button to enable or disable chores in the scheduler. - The toggle will be available in both types of schedulers. - Added UI design proposal and considerations for mobile dimensions. - Included E2E tests to ensure toggle state persistence and correct behavior in child view.
This commit is contained in:
233
frontend/vue-app/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
233
frontend/vue-app/e2e/mode_parent/chore-scheduler/helpers.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// shared helpers for chore-scheduler spec files
|
||||
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
// ─── Date Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function todayISO(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function yesterdayISO(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function futureDateISO(daysAhead = 7): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + daysAhead)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Today's weekday (0 = Sunday, 6 = Saturday) — matches DayConfig.day */
|
||||
export function todayDayIndex(): number {
|
||||
return new Date().getDay()
|
||||
}
|
||||
|
||||
/** A weekday guaranteed not to be today */
|
||||
export function nonTodayDayIndex(): number {
|
||||
return (new Date().getDay() + 1) % 7
|
||||
}
|
||||
|
||||
// ─── API Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createChild(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/child/list')
|
||||
for (const c of (await pre.json()).children ?? []) {
|
||||
if (c.name === name) await request.delete(`/api/child/${c.id}`)
|
||||
}
|
||||
await request.put('/api/child/add', { data: { name, age: 8 } })
|
||||
const list = await request.get('/api/child/list')
|
||||
return (
|
||||
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
export async function createChoreTask(request: APIRequestContext, name: string): Promise<string> {
|
||||
const pre = await request.get('/api/task/list')
|
||||
for (const t of (await pre.json()).tasks ?? []) {
|
||||
if (t.name === name) await request.delete(`/api/task/${t.id}`)
|
||||
}
|
||||
await request.put('/api/task/add', { data: { name, points: 5, type: 'chore' } })
|
||||
const list = await request.get('/api/task/list')
|
||||
return (
|
||||
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
export async function assignChore(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
choreIds: string[],
|
||||
): Promise<void> {
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: choreIds, type: 'chore' },
|
||||
})
|
||||
}
|
||||
|
||||
export interface DaysScheduleOpts {
|
||||
hour?: number
|
||||
minute?: number
|
||||
hasDeadline?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export async function setDaysSchedule(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
days: number[],
|
||||
opts: DaysScheduleOpts = {},
|
||||
): Promise<void> {
|
||||
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
|
||||
const day_configs = days.map((day) => ({ day, hour, minute }))
|
||||
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||
data: {
|
||||
mode: 'days',
|
||||
day_configs,
|
||||
default_hour: hour,
|
||||
default_minute: minute,
|
||||
default_has_deadline: hasDeadline,
|
||||
enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface IntervalScheduleOpts {
|
||||
hour?: number
|
||||
minute?: number
|
||||
hasDeadline?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export async function setIntervalSchedule(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
intervalDays: number,
|
||||
anchorDate: string,
|
||||
opts: IntervalScheduleOpts = {},
|
||||
): Promise<void> {
|
||||
const { hour = 8, minute = 0, hasDeadline = true, enabled = true } = opts
|
||||
await request.put(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: intervalDays,
|
||||
anchor_date: anchorDate,
|
||||
interval_has_deadline: hasDeadline,
|
||||
interval_hour: hour,
|
||||
interval_minute: minute,
|
||||
enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSchedule(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
// 404 is acceptable (no schedule existed)
|
||||
await request.delete(`/api/child/${childId}/task/${taskId}/schedule`)
|
||||
}
|
||||
|
||||
// ─── UI Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Locator for the Chores section */
|
||||
export function choreSection(page: Page): Locator {
|
||||
return page.locator('.child-list-container').filter({
|
||||
has: page.locator('h3', { hasText: 'Chores' }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Locator for a named chore card in the Chores section */
|
||||
export function choreCard(page: Page, name: string): Locator {
|
||||
return choreSection(page)
|
||||
.locator('.item-card')
|
||||
.filter({
|
||||
has: page.locator('.item-name').getByText(name, { exact: true }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Click the chore card (select it), wait for ready state, then click Options */
|
||||
export async function openChoreOptions(page: Page, card: Locator): Promise<void> {
|
||||
await card.click()
|
||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||
await card.getByRole('button', { name: 'Options' }).click()
|
||||
}
|
||||
|
||||
/** Open the schedule modal for a named chore card */
|
||||
export async function openScheduleModal(page: Page, card: Locator): Promise<void> {
|
||||
await openChoreOptions(page, card)
|
||||
await page.getByRole('button', { name: 'Schedule' }).click()
|
||||
await expect(page.getByText('Schedule Chore')).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a TimePickerPopover to a specific time.
|
||||
* @param timePicker - the `.time-picker-popover` locator wrapping the popover
|
||||
*/
|
||||
export async function setTimePicker(
|
||||
page: Page,
|
||||
timePicker: Locator,
|
||||
hour12: number,
|
||||
minute: number,
|
||||
period: 'AM' | 'PM',
|
||||
): Promise<void> {
|
||||
await timePicker.locator('.time-display').click()
|
||||
// Scope panel to this specific time picker to avoid strict-mode violations
|
||||
const panel = timePicker.locator('.popover-panel')
|
||||
await panel.waitFor()
|
||||
// Use exact regex patterns — getByRole name uses substring matching by default,
|
||||
// so "3" would also match "30" and "1" would match "10", "11", "12" etc.
|
||||
await panel.getByRole('button', { name: new RegExp(`^${period}$`) }).click()
|
||||
await panel.getByRole('button', { name: new RegExp(`^${hour12}$`) }).click()
|
||||
await panel
|
||||
.getByRole('button', { name: new RegExp(`^${String(minute).padStart(2, '0')}$`) })
|
||||
.click()
|
||||
// Close popover by clicking the modal title (outside popover bounds)
|
||||
await page.locator('.modal-title').click()
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
/** Set the hidden date input and fire a change event */
|
||||
export async function setDateInput(page: Page, isoDate: string): Promise<void> {
|
||||
await page.locator('input.date-input-hidden').evaluate((el: HTMLInputElement, val: string) => {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, isoDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the child view for a given child.
|
||||
* Parent-authenticated users are blocked from /child by the auth guard, so we
|
||||
* must clear the parentAuth localStorage key before navigating there.
|
||||
*/
|
||||
export async function goToChildView(page: Page, childId: string): Promise<void> {
|
||||
// Go to the app domain first so we can access its localStorage
|
||||
await page.goto(`/parent/${childId}`)
|
||||
// Clear the parent auth token — router will now allow /child/:id
|
||||
await page.evaluate(() => localStorage.removeItem('parentAuth'))
|
||||
// Navigate to child view and wait for the chore section to appear
|
||||
await page.goto(`/child/${childId}`)
|
||||
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate (back) to the parent view from any page.
|
||||
* Restores the parentAuth localStorage key so the router allows /parent/:id.
|
||||
*/
|
||||
export async function goToParentView(page: Page, childId: string): Promise<void> {
|
||||
// Restore parent auth so the router guard allows /parent/:id
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'parentAuth',
|
||||
JSON.stringify({ expiresAt: Date.now() + 2 * 24 * 60 * 60 * 1000 }),
|
||||
)
|
||||
})
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.locator('.child-list-container').first().waitFor({ state: 'visible', timeout: 10000 })
|
||||
}
|
||||
Reference in New Issue
Block a user