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

364 lines
9.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
parseIcs,
mapPriorityToUrgency,
eventToTimer,
detectConflicts,
importCalendar,
} from './calendar-import';
import type { Timer } from './timer-engine';
// ── Fixtures ──────────────────────────────────────────────────
const SIMPLE_ICS = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:test-1@example.com
SUMMARY:Team Standup
DTSTART:20260315T090000
DTEND:20260315T093000
DESCRIPTION:Daily standup meeting
LOCATION:Room 42
PRIORITY:3
END:VEVENT
END:VCALENDAR`;
const MULTI_EVENT_ICS = `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:evt-1
SUMMARY:Morning Meeting
DTSTART:20260315T090000
DTEND:20260315T100000
END:VEVENT
BEGIN:VEVENT
UID:evt-2
SUMMARY:Lunch
DTSTART:20260315T120000
DTEND:20260315T130000
END:VEVENT
BEGIN:VEVENT
UID:evt-3
SUMMARY:Code Review
DTSTART:20260315T140000
DTEND:20260315T150000
PRIORITY:2
END:VEVENT
END:VCALENDAR`;
const UTC_ICS = `BEGIN:VCALENDAR
BEGIN:VEVENT
UID:utc-1
SUMMARY:UTC Event
DTSTART:20260315T140000Z
DTEND:20260315T150000Z
END:VEVENT
END:VCALENDAR`;
const DATE_ONLY_ICS = `BEGIN:VCALENDAR
BEGIN:VEVENT
UID:date-1
SUMMARY:All Day Event
DTSTART:20260315
END:VEVENT
END:VCALENDAR`;
const ESCAPED_ICS = `BEGIN:VCALENDAR
BEGIN:VEVENT
UID:esc-1
SUMMARY:Meeting\\, Important
DESCRIPTION:Details:\\nLine 2\\nLine 3
DTSTART:20260315T090000
END:VEVENT
END:VCALENDAR`;
const FOLDED_ICS = `BEGIN:VCALENDAR\r
BEGIN:VEVENT\r
UID:fold-1\r
SUMMARY:A very long summary that gets\r
folded across multiple lines\r
DTSTART:20260315T090000\r
END:VEVENT\r
END:VCALENDAR`;
const INVALID_ICS = `BEGIN:VCALENDAR
BEGIN:VEVENT
UID:bad-1
END:VEVENT
END:VCALENDAR`;
// ── Tests ─────────────────────────────────────────────────────
describe('parseIcs', () => {
it('parses a simple .ics file', () => {
const { events, errors } = parseIcs(SIMPLE_ICS);
expect(errors).toHaveLength(0);
expect(events).toHaveLength(1);
const evt = events[0];
expect(evt.uid).toBe('test-1@example.com');
expect(evt.summary).toBe('Team Standup');
expect(evt.description).toBe('Daily standup meeting');
expect(evt.location).toBe('Room 42');
expect(evt.priority).toBe(3);
expect(evt.dtstart.getFullYear()).toBe(2026);
expect(evt.dtstart.getMonth()).toBe(2); // March = 2 (0-indexed)
expect(evt.dtstart.getDate()).toBe(15);
expect(evt.dtstart.getHours()).toBe(9);
});
it('parses multiple events', () => {
const { events, errors } = parseIcs(MULTI_EVENT_ICS);
expect(errors).toHaveLength(0);
expect(events).toHaveLength(3);
expect(events[0].summary).toBe('Morning Meeting');
expect(events[1].summary).toBe('Lunch');
expect(events[2].summary).toBe('Code Review');
});
it('parses UTC datetime', () => {
const { events } = parseIcs(UTC_ICS);
expect(events).toHaveLength(1);
// UTC time: should be converted properly
expect(events[0].dtstart.getUTCHours()).toBe(14);
});
it('parses date-only events', () => {
const { events } = parseIcs(DATE_ONLY_ICS);
expect(events).toHaveLength(1);
expect(events[0].dtstart.getFullYear()).toBe(2026);
expect(events[0].dtstart.getMonth()).toBe(2);
expect(events[0].dtstart.getDate()).toBe(15);
});
it('unescapes special characters', () => {
const { events } = parseIcs(ESCAPED_ICS);
expect(events[0].summary).toBe('Meeting, Important');
expect(events[0].description).toBe('Details:\nLine 2\nLine 3');
});
it('unfolds continuation lines', () => {
const { events } = parseIcs(FOLDED_ICS);
expect(events).toHaveLength(1);
expect(events[0].summary).toBe('A very long summary that gets folded across multiple lines');
});
it('reports errors for events missing required fields', () => {
const { events, errors } = parseIcs(INVALID_ICS);
expect(events).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('missing required fields');
});
it('handles empty content', () => {
const { events, errors } = parseIcs('');
expect(events).toHaveLength(0);
expect(errors).toHaveLength(0);
});
});
describe('mapPriorityToUrgency', () => {
it('maps priority 1 to critical', () => {
expect(mapPriorityToUrgency(1)).toBe('critical');
});
it('maps priority 2 to critical', () => {
expect(mapPriorityToUrgency(2)).toBe('critical');
});
it('maps priority 3 to important', () => {
expect(mapPriorityToUrgency(3)).toBe('important');
});
it('maps priority 5 to standard', () => {
expect(mapPriorityToUrgency(5)).toBe('standard');
});
it('maps priority 7 to gentle', () => {
expect(mapPriorityToUrgency(7)).toBe('gentle');
});
it('maps priority 9 to passive', () => {
expect(mapPriorityToUrgency(9)).toBe('passive');
});
it('defaults to standard for undefined priority', () => {
expect(mapPriorityToUrgency(undefined)).toBe('standard');
});
it('defaults to standard for out-of-range priority', () => {
expect(mapPriorityToUrgency(0)).toBe('standard');
expect(mapPriorityToUrgency(10)).toBe('standard');
});
});
describe('eventToTimer', () => {
it('converts an event to a timer', () => {
const event = {
uid: 'test-1',
summary: 'Meeting',
dtstart: new Date(Date.now() + 3_600_000),
description: 'Important meeting',
priority: 3,
};
const timer = eventToTimer(event);
expect(timer.type).toBe('alarm');
expect(timer.label).toBe('Meeting');
expect(timer.urgency).toBe('important');
expect(timer.state).toBe('active');
expect(timer.cascade.preset).toBe('standard');
});
it('marks past events as dismissed', () => {
const event = {
uid: 'past-1',
summary: 'Past Event',
dtstart: new Date(Date.now() - 3_600_000),
};
const timer = eventToTimer(event);
expect(timer.state).toBe('dismissed');
});
it('includes location in description', () => {
const event = {
uid: 'loc-1',
summary: 'Meeting',
dtstart: new Date(Date.now() + 3_600_000),
location: 'Room 42',
description: 'Notes here',
};
const timer = eventToTimer(event);
expect(timer.description).toContain('Room 42');
expect(timer.description).toContain('Notes here');
});
});
describe('detectConflicts', () => {
const now = Date.now();
function makeExistingTimer(targetOffset: number): Timer {
return {
id: `existing-${Math.random()}`,
type: 'alarm',
label: 'Existing',
urgency: 'standard',
state: 'active',
targetTime: now + targetOffset,
duration: null,
createdAt: now,
startedAt: now,
pausedAt: null,
firedAt: null,
dismissedAt: null,
completedAt: null,
elapsedBeforePause: 0,
cascade: { preset: 'none', intervals: [] },
warnings: [],
snoozeCount: 0,
snoozedUntil: null,
};
}
it('detects overlapping timers', () => {
const event = {
uid: 'conflict-1',
summary: 'New Event',
dtstart: new Date(now + 3_600_000),
dtend: new Date(now + 7_200_000),
};
const existing = [
makeExistingTimer(3_600_000 + 5 * 60_000), // 5 min after event start
];
const conflicts = detectConflicts(event, existing);
expect(conflicts).toHaveLength(1);
});
it('does not flag non-overlapping timers', () => {
const event = {
uid: 'no-conflict',
summary: 'New Event',
dtstart: new Date(now + 3_600_000),
dtend: new Date(now + 7_200_000),
};
const existing = [
makeExistingTimer(24 * 3_600_000), // 24 hours later
];
const conflicts = detectConflicts(event, existing);
expect(conflicts).toHaveLength(0);
});
it('ignores dismissed timers', () => {
const event = {
uid: 'dismissed-test',
summary: 'New Event',
dtstart: new Date(now + 3_600_000),
dtend: new Date(now + 7_200_000),
};
const dismissed = makeExistingTimer(3_600_000 + 60_000);
dismissed.state = 'dismissed';
const conflicts = detectConflicts(event, [dismissed]);
expect(conflicts).toHaveLength(0);
});
});
describe('importCalendar', () => {
it('imports a full .ics file', () => {
const result = importCalendar(SIMPLE_ICS, [], Date.now());
expect(result.totalParsed).toBe(1);
expect(result.events).toHaveLength(1);
expect(result.events[0].timer.label).toBe('Team Standup');
});
it('imports multiple events', () => {
const result = importCalendar(MULTI_EVENT_ICS, []);
expect(result.totalParsed).toBe(3);
expect(result.events).toHaveLength(3);
});
it('reports parse errors', () => {
const result = importCalendar(INVALID_ICS, []);
expect(result.errors.length).toBeGreaterThan(0);
});
it('detects conflicts with existing timers', () => {
const now = Date.now();
// Create an existing timer at March 15, 2026 9:15 AM
const target = new Date(2026, 2, 15, 9, 15, 0).getTime();
const existing: Timer[] = [{
id: 'conflict-timer',
type: 'alarm',
label: 'Conflicting',
urgency: 'standard',
state: 'active',
targetTime: target,
duration: null,
createdAt: now,
startedAt: now,
pausedAt: null,
firedAt: null,
dismissedAt: null,
completedAt: null,
elapsedBeforePause: 0,
cascade: { preset: 'none', intervals: [] },
warnings: [],
snoozeCount: 0,
snoozedUntil: null,
}];
const result = importCalendar(SIMPLE_ICS, existing, now);
// The "Team Standup" event is at 9:00 AM March 15 — the existing timer at 9:15 is within window
const standup = result.events[0];
expect(standup.conflicts.length).toBeGreaterThan(0);
});
});