Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s

- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality.
- Created ScheduleModal component for scheduling chores with options for specific days or intervals.
- Added utility functions for scheduling logic in scheduleUtils.ts.
- Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
This commit is contained in:
2026-02-23 15:44:55 -05:00
parent d8822b44be
commit 234adbe05f
26 changed files with 2880 additions and 60 deletions

View File

@@ -111,3 +111,52 @@ export async function deleteChildOverride(childId: string, entityId: string): Pr
method: 'DELETE',
})
}
// ── Chore Schedule API ──────────────────────────────────────────────────────
/**
* Get the schedule for a specific child + task pair.
*/
export async function getChoreSchedule(childId: string, taskId: string): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/schedule`)
}
/**
* Create or replace the schedule for a specific child + task pair.
*/
export async function setChoreSchedule(
childId: string,
taskId: string,
schedule: object,
): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(schedule),
})
}
/**
* Delete the schedule for a specific child + task pair.
*/
export async function deleteChoreSchedule(childId: string, taskId: string): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
method: 'DELETE',
})
}
/**
* Extend a timed-out chore for the remainder of today only.
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
*/
export async function extendChoreTime(
childId: string,
taskId: string,
localDate: string,
): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/extend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }),
})
}

View File

@@ -8,6 +8,40 @@ export interface Task {
}
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
export interface DayConfig {
day: number // 0=Sun, 1=Mon, ..., 6=Sat
hour: number // 023 (24h)
minute: number // 0, 15, 30, or 45
}
export interface ChoreSchedule {
id: string
child_id: string
task_id: string
mode: 'days' | 'interval'
// mode='days'
day_configs: DayConfig[]
// mode='interval'
interval_days: number // 27
anchor_weekday: number // 0=Sun6=Sat
interval_hour: number
interval_minute: number
created_at: number
updated_at: number
}
export interface ChildTask {
id: string
name: string
is_good: boolean
points: number
image_id: string | null
image_url?: string | null
custom_value?: number | null
schedule?: ChoreSchedule | null
extension_date?: string | null // ISO date of today's extension, if any
}
export interface User {
id: string
first_name: string
@@ -97,6 +131,8 @@ export interface Event {
| TrackingEventCreatedPayload
| ChildOverrideSetPayload
| ChildOverrideDeletedPayload
| ChoreScheduleModifiedPayload
| ChoreTimeExtendedPayload
}
export interface ChildModifiedEventPayload {
@@ -213,3 +249,14 @@ export const CHILD_OVERRIDE_FIELDS = [
'created_at',
'updated_at',
] as const
export interface ChoreScheduleModifiedPayload {
child_id: string
task_id: string
operation: 'SET' | 'DELETED'
}
export interface ChoreTimeExtendedPayload {
child_id: string
task_id: string
}

View File

@@ -0,0 +1,123 @@
import type { ChoreSchedule, DayConfig } from './models'
/**
* Returns the JS day-of-week index for a given Date.
* 0 = Sunday, 6 = Saturday (matches Python / our DayConfig.day convention).
*/
function getLocalWeekday(d: Date): number {
return d.getDay()
}
/**
* Returns true if the interval schedule hits on the given localDate.
*
* Anchor: the most recent occurrence of `anchorWeekday` on or before today (this week).
* Pattern: every `intervalDays` days starting from that anchor.
*/
export function intervalHitsToday(
anchorWeekday: number,
intervalDays: number,
localDate: Date,
): boolean {
const todayWeekday = getLocalWeekday(localDate)
// Find the most recent anchorWeekday on or before today within the current week.
// We calculate the anchor as: (today - daysSinceAnchor)
const daysSinceAnchor = (todayWeekday - anchorWeekday + 7) % 7
const anchor = new Date(localDate)
anchor.setDate(anchor.getDate() - daysSinceAnchor)
anchor.setHours(0, 0, 0, 0)
const today = new Date(localDate)
today.setHours(0, 0, 0, 0)
const diffDays = Math.round((today.getTime() - anchor.getTime()) / 86400000)
return diffDays % intervalDays === 0
}
/**
* Returns true if the schedule applies today (localDate).
* - 'days' mode: today's weekday is in day_configs
* - 'interval' mode: intervalHitsToday
*/
export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean {
if (schedule.mode === 'days') {
const todayWeekday = getLocalWeekday(localDate)
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
} else {
return intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)
}
}
/**
* Returns the due time {hour, minute} for today, or null if no due time is configured
* for today (e.g. the day is not scheduled).
*/
export function getDueTimeToday(
schedule: ChoreSchedule,
localDate: Date,
): { hour: number; minute: number } | null {
if (schedule.mode === 'days') {
const todayWeekday = getLocalWeekday(localDate)
const dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday)
if (!dayConfig) return null
return { hour: dayConfig.hour, minute: dayConfig.minute }
} else {
if (!intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)) return null
return { hour: schedule.interval_hour, minute: schedule.interval_minute }
}
}
/**
* Returns true if the given due time has already passed given the current local time.
*/
export function isPastTime(dueHour: number, dueMinute: number, localNow: Date): boolean {
const nowMinutes = localNow.getHours() * 60 + localNow.getMinutes()
const dueMinutes = dueHour * 60 + dueMinute
return nowMinutes >= dueMinutes
}
/**
* Returns true if extensionDate matches today's local ISO date string.
*/
export function isExtendedToday(
extensionDate: string | null | undefined,
localDate: Date,
): boolean {
if (!extensionDate) return false
return extensionDate === toLocalISODate(localDate)
}
/**
* Formats a 24h hour/minute pair into a 12h "X:XX AM/PM" label.
*/
export function formatDueTimeLabel(hour: number, minute: number): string {
const period = hour < 12 ? 'AM' : 'PM'
const h12 = hour % 12 === 0 ? 12 : hour % 12
const mm = String(minute).padStart(2, '0')
return `${h12}:${mm} ${period}`
}
/**
* Returns the number of milliseconds from localNow until the due time fires today.
* Returns 0 if it has already passed.
*/
export function msUntilExpiry(dueHour: number, dueMinute: number, localNow: Date): number {
const nowMs =
localNow.getHours() * 3600000 +
localNow.getMinutes() * 60000 +
localNow.getSeconds() * 1000 +
localNow.getMilliseconds()
const dueMs = dueHour * 3600000 + dueMinute * 60000
return Math.max(0, dueMs - nowMs)
}
/**
* Returns the local date as an ISO date string (YYYY-MM-DD) without timezone conversion.
*/
export function toLocalISODate(d: Date): string {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}