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