learning_ai_clock/web/src/app/history/page.tsx
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

320 lines
13 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
import { useTimerStore } from '@/lib/store';
import { computeStreak } from '@/lib/stats';
import { StatsView } from '@/components/StatsView';
import { StreakCard } from '@/components/StreakCard';
import { TimerCard } from '@/components/TimerCard';
import { downloadExport, readFileAsText, parseImportData, importTimers } from '@/lib/export';
import { importCalendar } from '@/lib/calendar-import';
import { ArrowLeft, Download, Upload, Calendar, Search, Filter } from 'lucide-react';
import { getCategoryById, getAllCategories } from '@/lib/categories';
export default function HistoryPage() {
const timers = useTimerStore((s) => s.timers);
const now = useTimerStore((s) => s.now);
const [mounted, setMounted] = useState(false);
const [search, setSearch] = useState('');
const [filterCategory, setFilterCategory] = useState<string | ''>('');
const [filterUrgency, setFilterUrgency] = useState<string | ''>('');
const [importStatus, setImportStatus] = useState<string | null>(null);
const [tab, setTab] = useState<'stats' | 'history' | 'import'>('stats');
useEffect(() => { setMounted(true); }, []);
const streak = useMemo(() => computeStreak(timers, now), [timers, now]);
const completedTimers = useMemo(() => {
return timers
.filter((t) => ['dismissed', 'completed'].includes(t.state))
.filter((t) => {
if (search) {
const q = search.toLowerCase();
if (!t.label.toLowerCase().includes(q) && !t.description?.toLowerCase().includes(q)) {
return false;
}
}
if (filterCategory && t.category !== filterCategory) return false;
if (filterUrgency && t.urgency !== filterUrgency) return false;
return true;
})
.sort((a, b) => (b.completedAt ?? b.dismissedAt ?? b.createdAt) - (a.completedAt ?? a.dismissedAt ?? a.createdAt));
}, [timers, search, filterCategory, filterUrgency]);
const categories = useMemo(() => getAllCategories(), []);
if (!mounted) return null;
const handleExport = () => downloadExport(timers);
const handleJsonImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await readFileAsText(file);
const data = parseImportData(text);
if (!data) {
setImportStatus('Invalid file format. Expected a ChronoMind export JSON.');
return;
}
const result = importTimers(data, timers);
// Add the imported timers to the store
const existingIds = new Set(timers.map((t) => t.id));
const newTimers = data.timers.filter((t) => !existingIds.has(t.id));
if (newTimers.length > 0) {
useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] }));
}
setImportStatus(`Imported ${result.imported} timers. ${result.skipped} skipped.${result.errors.length > 0 ? ` Errors: ${result.errors.join(', ')}` : ''}`);
} catch {
setImportStatus('Failed to read file.');
}
e.target.value = '';
};
const handleIcsImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await readFileAsText(file);
const result = importCalendar(text, timers);
if (result.events.length === 0) {
setImportStatus(`No events found.${result.errors.length > 0 ? ` Errors: ${result.errors.join(', ')}` : ''}`);
return;
}
const newTimers = result.events.map((e) => e.timer);
useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] }));
const conflictCount = result.events.filter((e) => e.conflicts.length > 0).length;
setImportStatus(`Imported ${result.events.length} calendar events.${conflictCount > 0 ? ` ${conflictCount} have conflicts with existing timers.` : ''}`);
} catch {
setImportStatus('Failed to parse .ics file.');
}
e.target.value = '';
};
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="max-w-3xl mx-auto px-4 py-8">
<Link
href="/"
className="flex items-center gap-2 text-sm mb-6"
style={{ color: 'var(--cm-accent)' }}
>
<ArrowLeft size={16} /> Back to Dashboard
</Link>
<h1 className="text-2xl font-bold mb-6" style={{ color: 'var(--cm-text-primary)' }}>
History & Stats
</h1>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b" style={{ borderColor: 'var(--cm-border)' }}>
{[
{ key: 'stats' as const, label: 'Statistics' },
{ key: 'history' as const, label: 'History' },
{ key: 'import' as const, label: 'Import / Export' },
].map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer"
style={{
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)',
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
}}
>
{t.label}
</button>
))}
</div>
{/* Stats tab */}
{tab === 'stats' && (
<div className="space-y-6">
<StreakCard streak={streak} />
<StatsView />
</div>
)}
{/* History tab */}
{tab === 'history' && (
<div className="space-y-4">
{/* Search + filters */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1 relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{ color: 'var(--cm-text-tertiary)' }}
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search timers..."
className="w-full pl-9 pr-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-primary)',
}}
/>
</div>
<div className="flex gap-2">
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="px-3 py-2 rounded-lg border text-xs cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-secondary)',
}}
>
<option value="">All Categories</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
<select
value={filterUrgency}
onChange={(e) => setFilterUrgency(e.target.value)}
className="px-3 py-2 rounded-lg border text-xs cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-secondary)',
}}
>
<option value="">All Urgency</option>
<option value="critical">Critical</option>
<option value="important">Important</option>
<option value="standard">Standard</option>
<option value="gentle">Gentle</option>
<option value="passive">Passive</option>
</select>
</div>
</div>
{/* Timer list */}
{completedTimers.length > 0 ? (
<div className="space-y-3">
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
{completedTimers.length} timer{completedTimers.length !== 1 ? 's' : ''} in history
</p>
{completedTimers.map((timer) => (
<TimerCard key={timer.id} timer={timer} />
))}
</div>
) : (
<div className="text-center py-12">
<Filter size={48} className="mx-auto mb-3 opacity-20" style={{ color: 'var(--cm-text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
{search || filterCategory || filterUrgency
? 'No timers match your filters.'
: 'No completed timers yet.'}
</p>
</div>
)}
</div>
)}
{/* Import/Export tab */}
{tab === 'import' && (
<div className="space-y-6">
{/* Warning */}
<div
className="rounded-xl border p-4 text-sm"
style={{
backgroundColor: 'rgba(245, 158, 11, 0.1)',
borderColor: 'rgba(245, 158, 11, 0.3)',
color: 'var(--cm-warning)',
}}
>
Your timers are stored locally in your browser. Export regularly to back up your data.
Cloud sync is coming in a future version.
</div>
{/* Export */}
<div
className="rounded-xl border p-4"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
<Download size={16} /> Export Timers
</h3>
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
Download all {timers.length} timers as a JSON file.
</p>
<button
onClick={handleExport}
disabled={timers.length === 0}
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-30"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
Export JSON
</button>
</div>
{/* Import JSON */}
<div
className="rounded-xl border p-4"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
<Upload size={16} /> Import Timers (JSON)
</h3>
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
Restore timers from a previously exported ChronoMind JSON file.
</p>
<label
className="inline-block px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
Choose File
<input type="file" accept=".json" onChange={handleJsonImport} className="hidden" />
</label>
</div>
{/* Import .ics */}
<div
className="rounded-xl border p-4"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
<Calendar size={16} /> Import Calendar (.ics)
</h3>
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
Import events from a .ics file (Google Calendar, Outlook, Apple Calendar exports).
Events become alarms with auto-generated pre-warning cascades.
</p>
<label
className="inline-block px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
Choose .ics File
<input type="file" accept=".ics,.ical" onChange={handleIcsImport} className="hidden" />
</label>
</div>
{/* Import status */}
{importStatus && (
<div
className="rounded-xl border p-3 text-sm"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-secondary)',
}}
>
{importStatus}
</div>
)}
</div>
)}
</div>
</div>
);
}