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.
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
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}`
|
|
}
|