learning_ai_clock/web/src/lib/calendar-import.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

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,
};
}