learning_ai_clock/web/src/lib/recurrence.ts
saravanakumardb1 38bb2629e9 feat(web): Phase 2 — stats, categories, recurring, export/import, calendar .ics
- Statistics + streaks engine (lib/stats.ts) with daily/weekly/monthly breakdowns, on-time rate, focus time, streak tracking (23 tests)
- Categories/tags system (lib/categories.ts) with 6 built-in categories, custom tags, default urgency+cascade per category (29 tests)
- Recurring timer engine (lib/recurrence.ts) with daily/weekday/weekend/weekly/biweekly/monthly/custom rules, skip/pause, DST edge cases (37 tests)
- Timer export/import as JSON (lib/export.ts)
- Calendar .ics import (lib/calendar-import.ts) with RFC 5545 parsing, conflict detection, priority-to-urgency mapping (26 tests)
- StatsView component with Recharts (bar, line, pie charts)
- StreakCard component with milestone badges
- History page (/history) with stats, history search/filter, import/export
- Category picker in CreateTimerModal with auto urgency+cascade defaults
- Category filter chips on Dashboard + History link in header
- Installed recharts dependency
- Updated roadmap.md Phase 2 Week 4-5 with completion status
- 302 tests passing (up from 82 in Phase 1)
2026-02-27 21:59:09 -08:00

285 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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<RecurrenceFrequency, string> = {
daily: 'Every day',
weekday: 'Weekdays (MonFri)',
weekend: 'Weekends (SatSun)',
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}`;
}
}