460 lines
20 KiB
TypeScript
460 lines
20 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 { downloadExportAll, readFileAsText, parseImportData, importTimers, importRoutines } from '@/lib/export';
|
|
import { useRoutineStore } from '@/lib/routine-store';
|
|
import { importCalendar } from '@/lib/calendar-import';
|
|
import { ArrowLeft, Download, Upload, Calendar, Search, Filter, RotateCcw, FileSpreadsheet, Eye, Check, X } from 'lucide-react';
|
|
import { getCategoryById, getAllCategories } from '@/lib/categories';
|
|
import type { CalendarImportResult } from '@/lib/calendar-import';
|
|
import type { Timer } from '@/lib/timer-engine';
|
|
|
|
export default function HistoryPage() {
|
|
const timers = useTimerStore((s) => s.timers);
|
|
const now = useTimerStore((s) => s.now);
|
|
const routines = useRoutineStore((s) => s.routines);
|
|
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');
|
|
const [icsPreview, setIcsPreview] = useState<CalendarImportResult | null>(null);
|
|
|
|
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 = () => downloadExportAll(timers, routines);
|
|
|
|
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 timerResult = 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] }));
|
|
}
|
|
// Import routines if present
|
|
const routineResult = importRoutines(data, routines);
|
|
if (data.routines && data.routines.length > 0) {
|
|
const existingRoutineIds = new Set(routines.map((r) => r.id));
|
|
const newRoutines = data.routines.filter((r) => !existingRoutineIds.has(r.id));
|
|
if (newRoutines.length > 0) {
|
|
useRoutineStore.setState((s) => ({ routines: [...s.routines, ...newRoutines] }));
|
|
}
|
|
}
|
|
const parts = [`Imported ${timerResult.imported} timers`];
|
|
if (routineResult.imported > 0) parts.push(`${routineResult.imported} routines`);
|
|
const totalSkipped = timerResult.skipped + routineResult.skipped;
|
|
if (totalSkipped > 0) parts.push(`${totalSkipped} skipped`);
|
|
const allErrors = [...timerResult.errors, ...routineResult.errors];
|
|
setImportStatus(`${parts.join(', ')}.${allErrors.length > 0 ? ` Errors: ${allErrors.join(', ')}` : ''}`);
|
|
} catch {
|
|
setImportStatus('Failed to read file.');
|
|
}
|
|
e.target.value = '';
|
|
};
|
|
|
|
const handleIcsPreview = 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;
|
|
}
|
|
setIcsPreview(result);
|
|
setImportStatus(null);
|
|
} catch {
|
|
setImportStatus('Failed to parse .ics file.');
|
|
}
|
|
e.target.value = '';
|
|
};
|
|
|
|
const confirmIcsImport = () => {
|
|
if (!icsPreview) return;
|
|
const newTimers = icsPreview.events.map((e) => e.timer);
|
|
useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] }));
|
|
const conflictCount = icsPreview.events.filter((e) => e.conflicts.length > 0).length;
|
|
setImportStatus(`Imported ${icsPreview.events.length} calendar events.${conflictCount > 0 ? ` ${conflictCount} have conflicts.` : ''}`);
|
|
setIcsPreview(null);
|
|
};
|
|
|
|
const handleRepeatTimer = (timer: Timer) => {
|
|
if (timer.type === 'countdown' && timer.duration) {
|
|
useTimerStore.getState().addCountdown({ label: timer.label, durationMs: timer.duration, urgency: timer.urgency, category: timer.category });
|
|
} else if (timer.type === 'alarm') {
|
|
const target = new Date();
|
|
const orig = new Date(timer.targetTime);
|
|
target.setHours(orig.getHours(), orig.getMinutes(), 0, 0);
|
|
if (target.getTime() <= Date.now()) target.setDate(target.getDate() + 1);
|
|
useTimerStore.getState().addAlarm({ label: timer.label, targetTime: target.getTime(), urgency: timer.urgency, category: timer.category });
|
|
} else if (timer.type === 'event') {
|
|
return; // events are one-off
|
|
}
|
|
};
|
|
|
|
const handleCsvExport = () => {
|
|
const headers = ['Label', 'Type', 'State', 'Urgency', 'Category', 'Created', 'Completed', 'Duration (min)'];
|
|
const rows = timers.map((t) => [
|
|
`"${t.label.replace(/"/g, '""')}"`,
|
|
t.type,
|
|
t.state,
|
|
t.urgency,
|
|
t.category ?? '',
|
|
new Date(t.createdAt).toISOString(),
|
|
t.completedAt ? new Date(t.completedAt).toISOString() : t.dismissedAt ? new Date(t.dismissedAt).toISOString() : '',
|
|
t.duration ? Math.round(t.duration / 60_000).toString() : '',
|
|
]);
|
|
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `chronomind-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
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) => (
|
|
<div key={timer.id} className="relative">
|
|
<TimerCard timer={timer} />
|
|
{timer.type !== 'event' && (
|
|
<button
|
|
onClick={() => handleRepeatTimer(timer)}
|
|
className="absolute top-3 right-3 p-1.5 rounded-lg cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
|
|
title="Repeat this timer"
|
|
>
|
|
<RotateCcw size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</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: 'var(--cm-warning-10)',
|
|
borderColor: 'var(--cm-warning-30)',
|
|
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 Data
|
|
</h3>
|
|
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
Download all {timers.length} timers and {routines.length} routines as a JSON file.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={timers.length === 0 && routines.length === 0}
|
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-30"
|
|
style={{ backgroundColor: 'var(--cm-accent)', color: 'var(--cm-white)' }}
|
|
>
|
|
<Download size={14} /> Export JSON
|
|
</button>
|
|
<button
|
|
onClick={handleCsvExport}
|
|
disabled={timers.length === 0}
|
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-30"
|
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
|
>
|
|
<FileSpreadsheet size={14} /> Export CSV
|
|
</button>
|
|
</div>
|
|
</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 Data (JSON)
|
|
</h3>
|
|
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
Restore timers and routines 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={handleIcsPreview} className="hidden" />
|
|
</label>
|
|
</div>
|
|
|
|
{/* Calendar import preview */}
|
|
{icsPreview && (
|
|
<div
|
|
className="rounded-xl border p-4"
|
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
|
<Eye size={16} /> Preview: {icsPreview.events.length} events
|
|
</h3>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={confirmIcsImport}
|
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-success-15)', color: 'var(--cm-success)' }}
|
|
>
|
|
<Check size={14} /> Import All
|
|
</button>
|
|
<button
|
|
onClick={() => setIcsPreview(null)}
|
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-critical-15)', color: 'var(--cm-danger)' }}
|
|
>
|
|
<X size={14} /> Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
|
{icsPreview.events.map((evt, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center justify-between rounded-lg p-2 text-xs"
|
|
style={{ backgroundColor: 'var(--cm-surface-muted)' }}
|
|
>
|
|
<div>
|
|
<span className="font-medium" style={{ color: 'var(--cm-text-primary)' }}>{evt.timer.label}</span>
|
|
<span className="ml-2" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
{new Date(evt.timer.targetTime).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{evt.conflicts.length > 0 && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--cm-warning-15)', color: 'var(--cm-warning)' }}>
|
|
Conflict
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{icsPreview.errors.length > 0 && (
|
|
<p className="text-xs mt-2" style={{ color: 'var(--cm-danger)' }}>
|
|
{icsPreview.errors.length} error(s): {icsPreview.errors.slice(0, 3).join(', ')}
|
|
</p>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|