feat: Refactor ScheduleModal to support interval scheduling with date input and deadline toggle
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m30s

- Updated ChoreSchedule model to include anchor_date and interval_has_deadline.
- Refactored interval scheduling logic in scheduleUtils to use anchor_date.
- Introduced DateInputField component for selecting anchor dates in ScheduleModal.
- Enhanced ScheduleModal to include a stepper for interval days and a toggle for deadline.
- Updated tests for ScheduleModal and scheduleUtils to reflect new interval scheduling logic.
- Added DateInputField tests to ensure proper functionality and prop handling.
This commit is contained in:
2026-02-26 15:16:46 -05:00
parent 2403daa3f7
commit a197f8e206
12 changed files with 797 additions and 172 deletions

View File

@@ -24,8 +24,9 @@ export interface ChoreSchedule {
default_hour?: number // master deadline hour; present when mode='days'
default_minute?: number // master deadline minute; present when mode='days'
// mode='interval'
interval_days: number // 27
anchor_weekday: number // 0=Sun6=Sat
interval_days: number // 17
anchor_date: string // ISO date string e.g. "2026-02-25"; "" means use today
interval_has_deadline: boolean // false = "Anytime" (no deadline)
interval_hour: number
interval_minute: number
created_at: number

View File

@@ -11,27 +11,32 @@ function getLocalWeekday(d: Date): number {
/**
* 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.
* Anchor: the ISO date string from `anchor_date` (e.g. "2026-02-26").
* An empty string means "use localDate as anchor" (backward compat) which
* hits on today (diffDays=0) and every intervalDays days after.
*
* Dates before the anchor always return false (scheduling hasn't started yet).
*/
export function intervalHitsToday(
anchorWeekday: number,
anchorDate: string,
intervalDays: number,
localDate: Date,
): boolean {
const todayWeekday = getLocalWeekday(localDate)
// Parse anchor: use localDate itself when anchor is empty (backward compat)
let anchor: Date
if (anchorDate) {
const [y, m, d] = anchorDate.split('-').map(Number)
anchor = new Date(y, m - 1, d, 0, 0, 0, 0)
} else {
anchor = new Date(localDate)
anchor.setHours(0, 0, 0, 0)
}
// 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 target = new Date(localDate)
target.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)
const diffDays = Math.round((target.getTime() - anchor.getTime()) / 86_400_000)
if (diffDays < 0) return false // before anchor — not started yet
return diffDays % intervalDays === 0
}
@@ -45,13 +50,16 @@ export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): bool
const todayWeekday = getLocalWeekday(localDate)
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
} else {
return intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)
return intervalHitsToday(schedule.anchor_date ?? '', 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).
* Returns the due time {hour, minute} for today, or null if:
* - the day is not scheduled, OR
* - the schedule has no deadline (interval_has_deadline === false → "Anytime")
*
* Callers treat null as "active all day with no expiry".
*/
export function getDueTimeToday(
schedule: ChoreSchedule,
@@ -63,7 +71,10 @@ export function getDueTimeToday(
if (!dayConfig) return null
return { hour: dayConfig.hour, minute: dayConfig.minute }
} else {
if (!intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)) return null
if (!intervalHitsToday(schedule.anchor_date ?? '', schedule.interval_days, localDate))
return null
// interval_has_deadline === false means "Anytime" — no expiry time
if (schedule.interval_has_deadline === false) return null
return { hour: schedule.interval_hour, minute: schedule.interval_minute }
}
}