From f61483e7a53b9b96ad4a4a2b6f247dc3994f7a0e Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:28:36 -0800 Subject: [PATCH] feat(web): calendar import preview, repeat timer, CSV export, compact mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/roadmap.md | 11 +-- web/src/app/history/page.tsx | 156 ++++++++++++++++++++++++++++++---- web/src/app/settings/page.tsx | 33 ++++++- 3 files changed, 178 insertions(+), 22 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8ffea30..48d92c4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/web/src/app/history/page.tsx b/web/src/app/history/page.tsx index 3f534b7..ac89ac5 100644 --- a/web/src/app/history/page.tsx +++ b/web/src/app/history/page.tsx @@ -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(''); const [importStatus, setImportStatus] = useState(null); const [tab, setTab] = useState<'stats' | 'history' | 'import'>('stats'); + const [icsPreview, setIcsPreview] = useState(null); useEffect(() => { setMounted(true); }, []); @@ -73,7 +76,7 @@ export default function HistoryPage() { e.target.value = ''; }; - const handleIcsImport = async (e: React.ChangeEvent) => { + const handleIcsPreview = async (e: React.ChangeEvent) => { 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 (
@@ -204,7 +250,19 @@ export default function HistoryPage() { {completedTimers.length} timer{completedTimers.length !== 1 ? 's' : ''} in history

{completedTimers.map((timer) => ( - +
+ + {timer.type !== 'event' && ( + + )} +
))}
) : ( @@ -247,14 +305,24 @@ export default function HistoryPage() {

Download all {timers.length} timers as a JSON file.

- +
+ + +
{/* Import JSON */} @@ -294,10 +362,66 @@ export default function HistoryPage() { style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }} > Choose .ics File - + + {/* Calendar import preview */} + {icsPreview && ( +
+
+

+ Preview: {icsPreview.events.length} events +

+
+ + +
+
+
+ {icsPreview.events.map((evt, i) => ( +
+
+ {evt.timer.label} + + {new Date(evt.timer.targetTime).toLocaleString()} + +
+ {evt.conflicts.length > 0 && ( + + Conflict + + )} +
+ ))} +
+ {icsPreview.errors.length > 0 && ( +

+ {icsPreview.errors.length} error(s): {icsPreview.errors.slice(0, 3).join(', ')} +

+ )} +
+ )} + {/* Import status */} {importStatus && (
('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'}
+ + {/* Compact mode */} +
+
+

+ Compact Mode +

+

+ Denser UI with smaller cards and reduced spacing. For power users. +

+
+ +