import type { Schedule, CalendarSettings } from './contracts'; const DAY = 86400000; export const dayNumber = (date: string) => Date.parse(`${date}T00:00:00Z`) / DAY; export const addDays = (date: string, n: number) => new Date((dayNumber(date) + n) * DAY).toISOString().slice(0, 10); const dateFormatters = new Map(); export function localDate(timestamp: number, timezone: string): string { let formatter = dateFormatters.get(timezone); if (!formatter) { formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }); dateFormatters.set(timezone, formatter); } return formatter.format(timestamp); } // Search an instant boundary rather than adding 24h: DST days can be 23 or 25h. export function endOfDay(date: string, timezone: string): number { const target = addDays(date, 1); let lo = Date.parse(`${target}T00:00:00Z`) - 36 * 3600000; let hi = lo + 72 * 3600000; while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); if (localDate(mid, timezone) < target) lo = mid; else hi = mid; } return hi; } export function scheduled(rule: Schedule, date: string): boolean { const day = dayNumber(date); const weekday = new Date(day * DAY).getUTCDay(); if (rule.type === 'daily') return true; if (rule.type === 'weekdays') return rule.days.includes(weekday); const anchor = dayNumber(rule.anchor); if (day < anchor) return false; if (rule.type === 'interval') return (day - anchor) % rule.every === 0; // Weeks start Monday; the anchor identifies week zero, even if not the due weekday. const monday = (n: number) => n - ((new Date(n * DAY).getUTCDay() + 6) % 7); return weekday === rule.weekday && (monday(day) - monday(anchor)) / 7 % rule.every === 0; } /** Each rule is a set of residues on a finite repeating period. */ export function schedulesOverlap(a: Schedule, b: Schedule): boolean { const mod = (n: number, period: number) => ((n % period) + period) % period; function cycle(rule: Schedule): { period: number; residues: number[] } { if (rule.type === 'daily') return { period: 1, residues: [0] }; // Unix day zero is Thursday (weekday 4). if (rule.type === 'weekdays') return { period: 7, residues: rule.days.map(day => mod(day - 4, 7)) }; const anchor = dayNumber(rule.anchor); if (rule.type === 'interval') return { period: rule.every, residues: [mod(anchor, rule.every)] }; const monday = anchor - ((new Date(anchor * DAY).getUTCDay() + 6) % 7); return { period: 7 * rule.every, residues: [mod(monday + (rule.weekday + 6) % 7, 7 * rule.every)] }; } const left = cycle(a); const right = cycle(b); let x = left.period; let y = right.period; while (y) { const remainder = x % y; x = y; y = remainder; } // Congruences intersect iff their residues agree modulo the gcd. Anchors // only restrict the lower bound; a compatible repeating solution recurs. return left.residues.some(l => right.residues.some(r => mod(l - r, x) === 0)); } export function shade(ratio: number | null, steps: number, settings: CalendarSettings, exactSteps: boolean) { if (ratio === null) return { level: null, color: settings.notDueColor }; if (ratio <= 0) return { level: 0, color: settings.emptyColor }; const total = steps; const level = exactSteps ? Math.min(total, Math.round(ratio * total)) : ratio >= 1 ? total : Math.min(total - (total > 1 ? 1 : 0), Math.max(1, Math.floor(ratio * total))); const intensity = total <= 1 ? 1 : 0.2 + 0.8 * (level - 1) / (total - 1); const luminance = (0.2126 * parseInt(settings.mainColor.slice(1, 3), 16) + 0.7152 * parseInt(settings.mainColor.slice(3, 5), 16) + 0.0722 * parseInt(settings.mainColor.slice(5, 7), 16)) / 255; const base = luminance > 0.75 ? 0 : 255; const color = '#' + [1, 3, 5].map(i => Math.round(base * (1 - intensity) + parseInt(settings.mainColor.slice(i, i + 2), 16) * intensity).toString(16).padStart(2, '0')).join(''); return { level, color }; }