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
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:
123
frontend/vue-app/src/common/scheduleUtils.ts
Normal file
123
frontend/vue-app/src/common/scheduleUtils.ts
Normal 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}`
|
||||
}
|
||||
Reference in New Issue
Block a user