- 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)
268 lines
8.3 KiB
TypeScript
268 lines
8.3 KiB
TypeScript
// ── Calendar .ics Import ──────────────────────────────────────
|
|
// Parse iCalendar (.ics) files and import events as timers
|
|
|
|
import type { Timer } from './timer-engine';
|
|
import type { UrgencyLevel } from './urgency';
|
|
import type { CascadeConfig } from './cascade';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { calculateCascadeWarnings, getCascadeIntervals } from './cascade';
|
|
|
|
// ── Types ─────────────────────────────────────────────────────
|
|
|
|
export interface IcsEvent {
|
|
uid: string;
|
|
summary: string;
|
|
description?: string;
|
|
dtstart: Date;
|
|
dtend?: Date;
|
|
location?: string;
|
|
priority?: number; // 1-9, lower = higher priority
|
|
}
|
|
|
|
export interface ImportedEvent {
|
|
event: IcsEvent;
|
|
timer: Timer;
|
|
conflicts: string[]; // IDs of existing timers that overlap
|
|
}
|
|
|
|
export interface CalendarImportResult {
|
|
events: ImportedEvent[];
|
|
errors: string[];
|
|
totalParsed: number;
|
|
}
|
|
|
|
// ── .ics Parser ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse an .ics file content into events.
|
|
*/
|
|
export function parseIcs(content: string): { events: IcsEvent[]; errors: string[] } {
|
|
const events: IcsEvent[] = [];
|
|
const errors: string[] = [];
|
|
|
|
// Unfold lines (RFC 5545: continuation lines start with space/tab)
|
|
const unfolded = content.replace(/\r\n[ \t]/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
const lines = unfolded.split('\n');
|
|
|
|
let inEvent = false;
|
|
let currentEvent: Partial<IcsEvent> = {};
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
|
|
if (trimmed === 'BEGIN:VEVENT') {
|
|
inEvent = true;
|
|
currentEvent = {};
|
|
continue;
|
|
}
|
|
|
|
if (trimmed === 'END:VEVENT') {
|
|
inEvent = false;
|
|
if (currentEvent.summary && currentEvent.dtstart) {
|
|
events.push({
|
|
uid: currentEvent.uid ?? uuidv4(),
|
|
summary: currentEvent.summary,
|
|
description: currentEvent.description,
|
|
dtstart: currentEvent.dtstart,
|
|
dtend: currentEvent.dtend,
|
|
location: currentEvent.location,
|
|
priority: currentEvent.priority,
|
|
});
|
|
} else {
|
|
errors.push(`Event missing required fields (SUMMARY or DTSTART)`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!inEvent) continue;
|
|
|
|
// Parse property
|
|
const colonIdx = trimmed.indexOf(':');
|
|
if (colonIdx === -1) continue;
|
|
|
|
const propPart = trimmed.slice(0, colonIdx);
|
|
const value = trimmed.slice(colonIdx + 1);
|
|
|
|
// Strip parameters (e.g., DTSTART;TZID=America/New_York:20260315T090000)
|
|
const propName = propPart.split(';')[0].toUpperCase();
|
|
|
|
switch (propName) {
|
|
case 'UID':
|
|
currentEvent.uid = value;
|
|
break;
|
|
case 'SUMMARY':
|
|
currentEvent.summary = unescapeIcsText(value);
|
|
break;
|
|
case 'DESCRIPTION':
|
|
currentEvent.description = unescapeIcsText(value);
|
|
break;
|
|
case 'LOCATION':
|
|
currentEvent.location = unescapeIcsText(value);
|
|
break;
|
|
case 'DTSTART':
|
|
currentEvent.dtstart = parseIcsDateTime(value);
|
|
break;
|
|
case 'DTEND':
|
|
currentEvent.dtend = parseIcsDateTime(value);
|
|
break;
|
|
case 'PRIORITY':
|
|
currentEvent.priority = parseInt(value, 10) || undefined;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { events, errors };
|
|
}
|
|
|
|
/**
|
|
* Parse iCalendar date-time string.
|
|
* Formats: 20260315T090000, 20260315T090000Z, 20260315
|
|
*/
|
|
function parseIcsDateTime(value: string): Date {
|
|
const cleaned = value.trim();
|
|
|
|
// Date only: YYYYMMDD
|
|
if (cleaned.length === 8) {
|
|
const y = parseInt(cleaned.slice(0, 4));
|
|
const m = parseInt(cleaned.slice(4, 6)) - 1;
|
|
const d = parseInt(cleaned.slice(6, 8));
|
|
return new Date(y, m, d);
|
|
}
|
|
|
|
// DateTime: YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ
|
|
const isUtc = cleaned.endsWith('Z');
|
|
const dt = isUtc ? cleaned.slice(0, -1) : cleaned;
|
|
const parts = dt.split('T');
|
|
|
|
const y = parseInt(parts[0].slice(0, 4));
|
|
const m = parseInt(parts[0].slice(4, 6)) - 1;
|
|
const d = parseInt(parts[0].slice(6, 8));
|
|
const h = parts[1] ? parseInt(parts[1].slice(0, 2)) : 0;
|
|
const min = parts[1] ? parseInt(parts[1].slice(2, 4)) : 0;
|
|
const s = parts[1] ? parseInt(parts[1].slice(4, 6)) : 0;
|
|
|
|
if (isUtc) {
|
|
return new Date(Date.UTC(y, m, d, h, min, s));
|
|
}
|
|
return new Date(y, m, d, h, min, s);
|
|
}
|
|
|
|
/**
|
|
* Unescape iCalendar text values.
|
|
*/
|
|
function unescapeIcsText(text: string): string {
|
|
return text
|
|
.replace(/\\n/g, '\n')
|
|
.replace(/\\,/g, ',')
|
|
.replace(/\\;/g, ';')
|
|
.replace(/\\\\/g, '\\');
|
|
}
|
|
|
|
// ── Event → Timer Conversion ──────────────────────────────────
|
|
|
|
/**
|
|
* Map iCalendar priority (1-9) to ChronoMind urgency level.
|
|
* 1-2: critical, 3-4: important, 5: standard, 6-7: gentle, 8-9: passive
|
|
*/
|
|
export function mapPriorityToUrgency(priority?: number): UrgencyLevel {
|
|
if (!priority || priority < 1 || priority > 9) return 'standard';
|
|
if (priority <= 2) return 'critical';
|
|
if (priority <= 4) return 'important';
|
|
if (priority <= 5) return 'standard';
|
|
if (priority <= 7) return 'gentle';
|
|
return 'passive';
|
|
}
|
|
|
|
/**
|
|
* Convert an IcsEvent to a Timer with auto-generated cascade.
|
|
*/
|
|
export function eventToTimer(event: IcsEvent, now: number = Date.now()): Timer {
|
|
const urgency = mapPriorityToUrgency(event.priority);
|
|
const targetTime = event.dtstart.getTime();
|
|
|
|
// Auto-cascade based on urgency
|
|
const cascadePresetMap: Record<UrgencyLevel, CascadeConfig> = {
|
|
critical: { preset: 'aggressive', intervals: [] },
|
|
important: { preset: 'standard', intervals: [] },
|
|
standard: { preset: 'light', intervals: [] },
|
|
gentle: { preset: 'minimal', intervals: [] },
|
|
passive: { preset: 'none', intervals: [] },
|
|
};
|
|
|
|
const cascade = cascadePresetMap[urgency];
|
|
const intervals = getCascadeIntervals(cascade);
|
|
|
|
return {
|
|
id: uuidv4(),
|
|
type: 'alarm',
|
|
label: event.summary,
|
|
description: [event.description, event.location].filter(Boolean).join(' — '),
|
|
urgency,
|
|
state: targetTime > now ? 'active' : 'dismissed',
|
|
targetTime,
|
|
duration: event.dtend ? event.dtend.getTime() - targetTime : null,
|
|
createdAt: now,
|
|
startedAt: targetTime > now ? now : null,
|
|
pausedAt: null,
|
|
firedAt: targetTime <= now ? targetTime : null,
|
|
dismissedAt: targetTime <= now ? now : null,
|
|
completedAt: null,
|
|
elapsedBeforePause: 0,
|
|
cascade,
|
|
warnings: calculateCascadeWarnings(targetTime, intervals, now),
|
|
snoozeCount: 0,
|
|
snoozedUntil: null,
|
|
};
|
|
}
|
|
|
|
// ── Conflict Detection ────────────────────────────────────────
|
|
|
|
/**
|
|
* Detect conflicts between an event and existing timers.
|
|
* Conflict = existing timer fires within 15 minutes of event start.
|
|
*/
|
|
export function detectConflicts(
|
|
event: IcsEvent,
|
|
existingTimers: Timer[],
|
|
conflictWindowMs: number = 15 * 60 * 1000
|
|
): string[] {
|
|
const eventStart = event.dtstart.getTime();
|
|
const eventEnd = event.dtend?.getTime() ?? eventStart + 60 * 60 * 1000;
|
|
|
|
return existingTimers
|
|
.filter((t) => {
|
|
if (['dismissed', 'completed'].includes(t.state)) return false;
|
|
const timerTime = t.targetTime;
|
|
// Timer fires within event window or event starts within timer's range
|
|
return (
|
|
(timerTime >= eventStart - conflictWindowMs && timerTime <= eventEnd + conflictWindowMs)
|
|
);
|
|
})
|
|
.map((t) => t.id);
|
|
}
|
|
|
|
// ── Full Import Pipeline ──────────────────────────────────────
|
|
|
|
/**
|
|
* Parse .ics content and convert to importable timers with conflict detection.
|
|
*/
|
|
export function importCalendar(
|
|
icsContent: string,
|
|
existingTimers: Timer[],
|
|
now: number = Date.now()
|
|
): CalendarImportResult {
|
|
const { events: icsEvents, errors: parseErrors } = parseIcs(icsContent);
|
|
|
|
const importedEvents: ImportedEvent[] = icsEvents.map((event) => {
|
|
const timer = eventToTimer(event, now);
|
|
const conflicts = detectConflicts(event, existingTimers);
|
|
return { event, timer, conflicts };
|
|
});
|
|
|
|
return {
|
|
events: importedEvents,
|
|
errors: parseErrors,
|
|
totalParsed: icsEvents.length,
|
|
};
|
|
}
|