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:
parent
48a4b7d024
commit
f61483e7a5
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user