// ── Recurring Timer Engine ──────────────────────────────────── // Recurrence rules, next-occurrence calculation, skip/pause logic export type RecurrenceFrequency = | 'daily' | 'weekday' | 'weekend' | 'weekly' | 'biweekly' | 'monthly' | 'custom'; export interface RecurrenceRule { frequency: RecurrenceFrequency; daysOfWeek?: number[]; // 0=Sun, 1=Mon, ..., 6=Sat (for 'custom' frequency) interval?: number; // Every N periods (default 1) endDate?: number; // epoch ms — stop recurring after this timeOfDay: number; // minutes since midnight (e.g., 540 = 9:00 AM) } export interface RecurringTimer { id: string; recurrence: RecurrenceRule; paused: boolean; skipNext: boolean; lastOccurrence: number | null; // epoch ms of last generated occurrence } export const RECURRENCE_LABELS: Record = { daily: 'Every day', weekday: 'Weekdays (Mon–Fri)', weekend: 'Weekends (Sat–Sun)', weekly: 'Every week', biweekly: 'Every 2 weeks', monthly: 'Every month', custom: 'Custom days', }; const DAY_MS = 24 * 60 * 60 * 1000; // ── Next Occurrence Calculation ─────────────────────────────── /** * Calculate the next occurrence of a recurring timer after `afterDate`. * Returns epoch ms of next occurrence, or null if no more occurrences. */ export function getNextOccurrence( rule: RecurrenceRule, afterDate: number, maxLookaheadDays: number = 366 ): number | null { const { frequency, timeOfDay, endDate, interval = 1 } = rule; // Start from the day after afterDate const after = new Date(afterDate); const startDay = new Date(after.getFullYear(), after.getMonth(), after.getDate()); // Set time of day const setTimeOnDate = (d: Date): Date => { const result = new Date(d.getFullYear(), d.getMonth(), d.getDate()); result.setMinutes(timeOfDay); return result; }; // Check if a candidate on the same day as afterDate still works const sameDayCandidate = setTimeOnDate(startDay); const candidates: Date[] = []; if (sameDayCandidate.getTime() > afterDate) { candidates.push(sameDayCandidate); } // Generate candidates going forward const limit = new Date(startDay.getTime() + maxLookaheadDays * DAY_MS); for (let d = new Date(startDay.getTime() + DAY_MS); d <= limit; d = new Date(d.getTime() + DAY_MS)) { candidates.push(setTimeOnDate(d)); if (candidates.length > maxLookaheadDays) break; } for (const candidate of candidates) { const ts = candidate.getTime(); // Check end date if (endDate && ts > endDate) return null; // Check if this day matches the frequency if (matchesFrequency(candidate, rule, afterDate, interval)) { return ts; } } return null; } /** * Check if a given date matches the recurrence frequency rule. */ function matchesFrequency( date: Date, rule: RecurrenceRule, _afterDate: number, interval: number ): boolean { const dayOfWeek = date.getDay(); // 0=Sun switch (rule.frequency) { case 'daily': return true; case 'weekday': return dayOfWeek >= 1 && dayOfWeek <= 5; case 'weekend': return dayOfWeek === 0 || dayOfWeek === 6; case 'weekly': { // Same day of week as the reference day, with interval const refDay = new Date(_afterDate).getDay(); if (dayOfWeek !== refDay) return false; if (interval <= 1) return true; const refDate = new Date(_afterDate); const refStart = new Date(refDate.getFullYear(), refDate.getMonth(), refDate.getDate()); const diffDays = Math.round((date.getTime() - refStart.getTime()) / DAY_MS); return diffDays % (interval * 7) === 0 || diffDays % (interval * 7) === 7; } case 'biweekly': { const refDay2 = new Date(_afterDate).getDay(); if (dayOfWeek !== refDay2) return false; const refDate2 = new Date(_afterDate); const refStart2 = new Date(refDate2.getFullYear(), refDate2.getMonth(), refDate2.getDate()); const diffDays2 = Math.round((date.getTime() - refStart2.getTime()) / DAY_MS); return diffDays2 >= 0 && diffDays2 % 14 < 7; } case 'monthly': { // Same day of month — handle months with fewer days const refDayOfMonth = new Date(_afterDate).getDate(); const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); const targetDay = Math.min(refDayOfMonth, daysInMonth); return date.getDate() === targetDay; } case 'custom': return rule.daysOfWeek ? rule.daysOfWeek.includes(dayOfWeek) : false; default: return false; } } // ── Bulk Helpers ────────────────────────────────────────────── /** * Get the next N occurrences of a recurring timer. */ export function getNextNOccurrences( rule: RecurrenceRule, afterDate: number, count: number ): number[] { const occurrences: number[] = []; let cursor = afterDate; for (let i = 0; i < count; i++) { const next = getNextOccurrence(rule, cursor); if (!next) break; occurrences.push(next); cursor = next; } return occurrences; } /** * Apply "skip next" — get the occurrence after the next one. */ export function getOccurrenceAfterSkip( rule: RecurrenceRule, afterDate: number ): number | null { const next = getNextOccurrence(rule, afterDate); if (!next) return null; return getNextOccurrence(rule, next); } // ── Rule Builders ───────────────────────────────────────────── /** * Create a daily recurrence rule at a specific time. */ export function createDailyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { return { frequency: 'daily', timeOfDay: timeOfDayMinutes, endDate }; } /** * Create a weekday recurrence rule. */ export function createWeekdayRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { return { frequency: 'weekday', timeOfDay: timeOfDayMinutes, endDate }; } /** * Create a weekend recurrence rule. */ export function createWeekendRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { return { frequency: 'weekend', timeOfDay: timeOfDayMinutes, endDate }; } /** * Create a weekly recurrence rule. */ export function createWeeklyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { return { frequency: 'weekly', timeOfDay: timeOfDayMinutes, interval: 1, endDate }; } /** * Create a biweekly recurrence rule. */ export function createBiweeklyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { return { frequency: 'biweekly', timeOfDay: timeOfDayMinutes, endDate }; } /** * Create a monthly recurrence rule. */ export function createMonthlyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { return { frequency: 'monthly', timeOfDay: timeOfDayMinutes, endDate }; } /** * Create a custom recurrence rule for specific days of the week. */ export function createCustomRule( daysOfWeek: number[], timeOfDayMinutes: number, endDate?: number ): RecurrenceRule { return { frequency: 'custom', daysOfWeek, timeOfDay: timeOfDayMinutes, endDate }; } // ── Display Helpers ─────────────────────────────────────────── /** * Format time-of-day minutes as "HH:MM AM/PM" */ export function formatTimeOfDay(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; const period = h >= 12 ? 'PM' : 'AM'; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${h12}:${String(m).padStart(2, '0')} ${period}`; } /** * Get a human-readable description of a recurrence rule. */ export function describeRecurrence(rule: RecurrenceRule): string { const time = formatTimeOfDay(rule.timeOfDay); const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; switch (rule.frequency) { case 'daily': return `Every day at ${time}`; case 'weekday': return `Weekdays at ${time}`; case 'weekend': return `Weekends at ${time}`; case 'weekly': return `Every week at ${time}`; case 'biweekly': return `Every 2 weeks at ${time}`; case 'monthly': return `Monthly at ${time}`; case 'custom': { if (!rule.daysOfWeek || rule.daysOfWeek.length === 0) return `Custom at ${time}`; const days = rule.daysOfWeek.sort().map((d) => dayNames[d]).join(', '); return `${days} at ${time}`; } default: return `At ${time}`; } }