- 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)
285 lines
8.6 KiB
TypeScript
285 lines
8.6 KiB
TypeScript
// ── 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 (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}`;
|
||
}
|
||
}
|