feat(web): calendar import preview, repeat timer, CSV export, compact mode

- Calendar import preview: parse .ics → show events with conflict indicators → confirm/cancel before importing
- History page: repeat timer button (recreates alarm at same time-of-day, countdown with same duration)
- History page: CSV export (Label, Type, State, Urgency, Category, Created, Completed, Duration)
- Settings: compact mode toggle (persisted to localStorage, sets data-compact attribute)
- Updated roadmap Week 5 items
- 373 tests across 16 files, tsc clean
This commit is contained in:
saravanakumardb1 2026-02-27 22:28:36 -08:00
parent 48a4b7d024
commit f61483e7a5
3 changed files with 178 additions and 22 deletions

View File

@ -350,7 +350,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Parse `.ics` file (iCalendar format) with RFC 5545 compliance (line unfolding, text escaping)
- [x] Import events as alarms with auto-generated cascade (urgency-based preset)
- [ ] Subscribe to calendar URL (re-fetch periodically)
- [ ] Import preview: show events before confirming
- [x] Import preview: show events before confirming (preview panel with conflict indicators)
- [x] Conflict detection: warn if imported event overlaps existing timer (15-min window)
- [x] Map calendar event priority (1-9) to urgency level
- [x] Support: UTC datetime, local datetime, date-only events
@ -366,7 +366,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Reduced cognitive load: quick presets, progressive disclosure in create modal
- [x] Time blindness aids: "About as long as [familiar reference]" ([d2b5563](https://github.com/saravanakumardb1/learning_ai_clock/commit/d2b5563))
- [ ] Body doubling placeholder: "Focus with others" room (v3 feature, show coming soon)
- [ ] Toggle in settings: "Compact mode" for power users who want dense UI
- [x] Toggle in settings: "Compact mode" for power users who want dense UI
- [x] **Prep time intelligence (`lib/prep-time.ts`)**
- [x] Per-timer: suggested prep + travel time based on label keywords and category
@ -396,7 +396,8 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] "Export timers" → JSON download of all timers
- [x] "Import timers" → upload JSON to restore (with deduplication)
- [x] Calendar .ics import on same page
- [ ] Warning: "Your timers are stored locally. Export regularly or enable cloud sync (coming in v1.1)."
- [x] CSV export for spreadsheet users
- [x] Warning banner: "Your timers are stored locally. Export regularly."
- [ ] **iOS waitlist**
- [ ] Waitlist signup on web landing page ("Get notified when iOS launches")
@ -406,8 +407,8 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] **Timer history (`app/history/page.tsx`)**
- [x] Searchable log of all past timers
- [x] Filter by category, urgency
- [ ] "Repeat" button to recreate a past timer
- [ ] Export as CSV
- [x] "Repeat" button to recreate a past timer (alarm at same time, countdown same duration)
- [x] Export as CSV (Label, Type, State, Urgency, Category, Created, Completed, Duration)
- [ ] **Playwright E2E tests**
- [ ] Create alarm → verify it appears on timeline

View File

@ -9,8 +9,10 @@ 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 { 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);
@ -21,6 +23,7 @@ export default function HistoryPage() {
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); }, []);
@ -73,7 +76,7 @@ export default function HistoryPage() {
e.target.value = '';
};
const handleIcsImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleIcsPreview = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
@ -83,16 +86,59 @@ export default function HistoryPage() {
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.` : ''}`);
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">
@ -204,7 +250,19 @@ export default function HistoryPage() {
{completedTimers.length} timer{completedTimers.length !== 1 ? 's' : ''} in history
</p>
{completedTimers.map((timer) => (
<TimerCard key={timer.id} timer={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>
) : (
@ -247,14 +305,24 @@ export default function HistoryPage() {
<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 className="flex gap-2">
<button
onClick={handleExport}
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-accent)', color: '#fff' }}
>
<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 */}
@ -294,10 +362,66 @@ export default function HistoryPage() {
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
Choose .ics File
<input type="file" accept=".ics,.ical" onChange={handleIcsImport} className="hidden" />
<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: 'rgba(52,211,153,0.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: 'rgba(255,71,87,0.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: 'rgba(245,158,11,0.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

View File

@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, Volume2, Bell, Palette, Trash2 } from 'lucide-react';
import { ArrowLeft, Volume2, Bell, Palette, Trash2, Minimize2 } from 'lucide-react';
import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
import { previewSound } from '@/lib/sounds';
import { getNotificationPermission, requestNotificationPermission } from '@/lib/notifications';
@ -13,6 +13,7 @@ import type { NotificationPermission as NotifPerm } from '@/lib/notifications';
export default function SettingsPage() {
const [mounted, setMounted] = useState(false);
const [notifPerm, setNotifPerm] = useState<NotifPerm>('default');
const [compactMode, setCompactMode] = useState(false);
const { theme, toggle: toggleTheme } = useTheme();
const timers = useTimerStore((s) => s.timers);
const removeTimer = useTimerStore((s) => s.removeTimer);
@ -20,8 +21,16 @@ export default function SettingsPage() {
useEffect(() => {
setMounted(true);
setNotifPerm(getNotificationPermission());
setCompactMode(localStorage.getItem('chronomind-compact-mode') === 'true');
}, []);
const toggleCompactMode = () => {
const next = !compactMode;
setCompactMode(next);
localStorage.setItem('chronomind-compact-mode', String(next));
document.documentElement.setAttribute('data-compact', String(next));
};
if (!mounted) return null;
const completedCount = timers.filter((t) => ['dismissed', 'completed'].includes(t.state)).length;
@ -71,6 +80,28 @@ export default function SettingsPage() {
Switch to {theme === 'dark' ? 'Light' : 'Dark'}
</button>
</div>
{/* Compact mode */}
<div className="flex items-center justify-between mt-4 pt-4" style={{ borderTop: '1px solid var(--cm-border)' }}>
<div>
<p className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>
<Minimize2 size={14} className="inline mr-1.5" /> Compact Mode
</p>
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Denser UI with smaller cards and reduced spacing. For power users.
</p>
</div>
<button
onClick={toggleCompactMode}
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
style={{
backgroundColor: compactMode ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
color: compactMode ? '#fff' : 'var(--cm-text-secondary)',
}}
>
{compactMode ? 'On' : 'Off'}
</button>
</div>
</div>
</section>