392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useTimerStore } from '@/lib/store';
|
|
import { useTickLoop } from '@/lib/use-tick';
|
|
import { useKeyboardShortcuts, SHORTCUT_MAP } from '@/lib/use-keyboard-shortcuts';
|
|
import { TimerCard } from './TimerCard';
|
|
import { PomodoroView } from './PomodoroView';
|
|
import { QuickTimerBar } from './QuickTimerBar';
|
|
import { CreateTimerModal } from './CreateTimerModal';
|
|
import { AlarmOverlay } from './AlarmOverlay';
|
|
import { requestNotificationPermission } from '@/lib/notifications';
|
|
import { formatTime, formatDate } from '@/lib/format';
|
|
import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye, BarChart3, ListChecks, Cloud, CloudOff, Loader2 } from 'lucide-react';
|
|
import { useSync } from '@/lib/use-sync';
|
|
import { BUILT_IN_CATEGORIES, matchesCategory } from '@/lib/categories';
|
|
import Link from 'next/link';
|
|
import { FeedbackButton } from './FeedbackButton';
|
|
import { InstallPrompt } from './InstallPrompt';
|
|
import { useTheme } from '@/lib/use-theme';
|
|
import { getSnoozeSuggestions } from '@/lib/adaptive-snooze';
|
|
import type { SnoozeSuggestion } from '@/lib/adaptive-snooze';
|
|
|
|
export function Dashboard() {
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
|
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<string>>(new Set());
|
|
const [snoozeSuggestions, setSnoozeSuggestions] = useState<SnoozeSuggestion[]>([]);
|
|
const timers = useTimerStore((s) => s.timers);
|
|
const now = useTimerStore((s) => s.now);
|
|
const { pause, resume } = useTimerStore();
|
|
const { theme, toggle: toggleTheme } = useTheme();
|
|
|
|
// Cloud sync
|
|
const { isSyncing, syncEnabled, pendingChanges, lastError } = useSync();
|
|
|
|
// Start the tick loop
|
|
useTickLoop();
|
|
|
|
// Keyboard shortcuts
|
|
const getFirstActiveTimer = useCallback(() => {
|
|
return timers.find((t) => ['active', 'warning', 'paused'].includes(t.state));
|
|
}, [timers]);
|
|
|
|
useKeyboardShortcuts({
|
|
onNewTimer: () => setIsCreateOpen(true),
|
|
onQuickTimer: () => setIsCreateOpen(true),
|
|
onTogglePause: () => {
|
|
const t = getFirstActiveTimer();
|
|
if (!t) return;
|
|
if (t.state === 'paused') resume(t.id);
|
|
else if (t.type !== 'alarm') pause(t.id);
|
|
},
|
|
onDismiss: () => {
|
|
if (isCreateOpen) setIsCreateOpen(false);
|
|
else if (showShortcuts) setShowShortcuts(false);
|
|
},
|
|
onShowHelp: () => setShowShortcuts((p) => !p),
|
|
});
|
|
|
|
// Hydration guard
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
requestNotificationPermission();
|
|
setSnoozeSuggestions(getSnoozeSuggestions());
|
|
}, []);
|
|
|
|
if (!mounted) {
|
|
return (
|
|
<div
|
|
className="min-h-screen flex items-center justify-center"
|
|
style={{ backgroundColor: 'var(--cm-bg-canvas)' }}
|
|
>
|
|
<Clock size={32} className="animate-spin" style={{ color: 'var(--cm-accent)' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activeTimers = timers
|
|
.filter((t) => ['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state))
|
|
.filter((t) => matchesCategory(t.category, filterCategory));
|
|
const completedTimers = timers
|
|
.filter((t) => ['dismissed', 'completed'].includes(t.state))
|
|
.filter((t) => matchesCategory(t.category, filterCategory))
|
|
.slice(-10)
|
|
.reverse();
|
|
|
|
// Tab title update with flash on fire
|
|
const hasFiring = timers.some((t) => t.state === 'firing');
|
|
useEffect(() => {
|
|
if (hasFiring) {
|
|
// Flash between alarm text and timer label
|
|
const firingTimer = timers.find((t) => t.state === 'firing');
|
|
const label = firingTimer?.label ?? 'Timer';
|
|
let flash = true;
|
|
const interval = setInterval(() => {
|
|
document.title = flash ? `🔔 TIME! — ${label}` : `⏰ ${label} | ChronoMind`;
|
|
flash = !flash;
|
|
}, 800);
|
|
return () => clearInterval(interval);
|
|
}
|
|
|
|
const next = activeTimers
|
|
.filter((t) => ['active', 'warning'].includes(t.state))
|
|
.sort((a, b) => a.targetTime - b.targetTime)[0];
|
|
|
|
if (next) {
|
|
const remaining = Math.max(0, next.targetTime - now);
|
|
const mins = Math.floor(remaining / 60000);
|
|
const secs = Math.floor((remaining % 60000) / 1000);
|
|
document.title = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')} — ${next.label} | ChronoMind`;
|
|
} else {
|
|
document.title = 'ChronoMind — Smart Pre-Warning Timer';
|
|
}
|
|
}, [now, activeTimers, hasFiring, timers]);
|
|
|
|
return (
|
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
|
{/* Skip to content */}
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-semibold"
|
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
|
>
|
|
Skip to content
|
|
</a>
|
|
|
|
{/* Alarm overlay for firing timers */}
|
|
<AlarmOverlay />
|
|
|
|
{/* Header */}
|
|
<header
|
|
className="sticky top-0 z-40 border-b backdrop-blur-md"
|
|
style={{
|
|
backgroundColor: 'rgba(6, 7, 10, 0.85)',
|
|
borderColor: 'var(--cm-border)',
|
|
}}
|
|
>
|
|
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Clock size={24} style={{ color: 'var(--cm-accent)' }} />
|
|
<h1 className="text-lg font-bold" style={{ color: 'var(--cm-text-primary)' }}>
|
|
ChronoMind
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
{formatTime(now)} · {formatDate(now)}
|
|
</span>
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-2 rounded-lg transition-colors cursor-pointer"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
>
|
|
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
|
</button>
|
|
<Link
|
|
href="/focus"
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title="Focus Mode"
|
|
aria-label="Focus Mode"
|
|
>
|
|
<Eye size={18} />
|
|
</Link>
|
|
<Link
|
|
href="/routines"
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title="Routines"
|
|
>
|
|
<ListChecks size={18} />
|
|
</Link>
|
|
<Link
|
|
href="/history"
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title="History & Stats"
|
|
aria-label="History & Stats"
|
|
>
|
|
<BarChart3 size={18} />
|
|
</Link>
|
|
<Link
|
|
href="/settings"
|
|
className="p-2 rounded-lg transition-colors"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title="Settings"
|
|
aria-label="Settings"
|
|
>
|
|
<Settings size={18} />
|
|
</Link>
|
|
{syncEnabled && (
|
|
<span
|
|
className="p-2 rounded-lg"
|
|
style={{ color: isSyncing ? 'var(--cm-accent)' : lastError ? 'var(--cm-critical)' : 'var(--cm-text-tertiary)' }}
|
|
title={isSyncing ? 'Syncing...' : lastError ? `Sync error: ${lastError}` : pendingChanges > 0 ? `${pendingChanges} pending` : 'Synced'}
|
|
>
|
|
{isSyncing ? <Loader2 size={16} className="animate-spin" /> : lastError ? <CloudOff size={16} /> : <Cloud size={16} />}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => setShowShortcuts((p) => !p)}
|
|
className="p-2 rounded-lg transition-colors cursor-pointer"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title="Keyboard shortcuts (?)"
|
|
aria-label="Keyboard shortcuts"
|
|
>
|
|
<Keyboard size={18} />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
|
>
|
|
<Plus size={16} /> New Timer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Keyboard shortcuts overlay */}
|
|
{showShortcuts && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/60" onClick={() => setShowShortcuts(false)} />
|
|
<div
|
|
className="relative rounded-2xl border p-6 max-w-sm w-full mx-4"
|
|
style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)' }}
|
|
>
|
|
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
|
Keyboard Shortcuts
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{SHORTCUT_MAP.map((s) => (
|
|
<div key={s.key} className="flex items-center justify-between">
|
|
<span className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>{s.description}</span>
|
|
<kbd
|
|
className="px-2 py-1 rounded text-xs font-mono"
|
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)' }}
|
|
>
|
|
{s.key}
|
|
</kbd>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<main id="main-content" className="max-w-3xl mx-auto px-4 py-6">
|
|
{/* Quick timer bar */}
|
|
<div className="mb-4">
|
|
<QuickTimerBar />
|
|
</div>
|
|
|
|
{/* Category filter */}
|
|
<div className="flex gap-1.5 mb-6 overflow-x-auto pb-1">
|
|
<button
|
|
onClick={() => setFilterCategory(null)}
|
|
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors cursor-pointer"
|
|
style={{
|
|
backgroundColor: !filterCategory ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
|
color: !filterCategory ? '#fff' : 'var(--cm-text-tertiary)',
|
|
}}
|
|
>
|
|
All
|
|
</button>
|
|
{BUILT_IN_CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => setFilterCategory(filterCategory === cat.id ? null : cat.id)}
|
|
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors cursor-pointer"
|
|
style={{
|
|
backgroundColor: filterCategory === cat.id ? `${cat.color}25` : 'var(--cm-surface-muted)',
|
|
color: filterCategory === cat.id ? cat.color : 'var(--cm-text-tertiary)',
|
|
border: filterCategory === cat.id ? `1px solid ${cat.color}50` : '1px solid transparent',
|
|
}}
|
|
>
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Snooze suggestions */}
|
|
{snoozeSuggestions.filter((s) => !dismissedSuggestions.has(s.labelPattern)).length > 0 && (
|
|
<div className="mb-4 space-y-2">
|
|
{snoozeSuggestions
|
|
.filter((s) => !dismissedSuggestions.has(s.labelPattern))
|
|
.slice(0, 2)
|
|
.map((suggestion) => (
|
|
<div
|
|
key={suggestion.labelPattern}
|
|
className="rounded-xl border p-3 flex items-center justify-between"
|
|
style={{
|
|
backgroundColor: 'rgba(90,140,255,0.08)',
|
|
borderColor: 'rgba(90,140,255,0.2)',
|
|
}}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium truncate" style={{ color: 'var(--cm-accent)' }}>
|
|
Snooze pattern detected
|
|
</p>
|
|
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--cm-text-secondary)' }}>
|
|
{suggestion.message}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setDismissedSuggestions((prev) => new Set([...prev, suggestion.labelPattern]))}
|
|
className="ml-3 text-xs px-2 py-1 rounded-lg cursor-pointer shrink-0"
|
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Active timers */}
|
|
{activeTimers.length > 0 ? (
|
|
<div className="space-y-3">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider flex items-center gap-2" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
<Bell size={14} /> Active ({activeTimers.length})
|
|
</h2>
|
|
{activeTimers
|
|
.sort((a, b) => a.targetTime - b.targetTime)
|
|
.map((timer) =>
|
|
timer.type === 'pomodoro' ? (
|
|
<PomodoroView key={timer.id} timer={timer} />
|
|
) : (
|
|
<TimerCard key={timer.id} timer={timer} />
|
|
)
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Empty state */
|
|
<div className="text-center py-20">
|
|
<Clock size={64} className="mx-auto mb-4 opacity-20" style={{ color: 'var(--cm-text-tertiary)' }} />
|
|
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--cm-text-primary)' }}>
|
|
No active timers
|
|
</h2>
|
|
<p className="mb-6" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
Create your first timer and never be caught off-guard again.
|
|
</p>
|
|
<button
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold transition-all cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
|
>
|
|
<Plus size={18} /> Create Timer
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Completed timers */}
|
|
{completedTimers.length > 0 && (
|
|
<div className="mt-8 space-y-3">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
Recent ({completedTimers.length})
|
|
</h2>
|
|
{completedTimers.map((timer) => (
|
|
<TimerCard key={timer.id} timer={timer} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Create Timer Modal */}
|
|
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
|
|
|
|
{/* Feedback button */}
|
|
<FeedbackButton />
|
|
|
|
{/* Install prompt */}
|
|
<div className="max-w-3xl mx-auto">
|
|
<InstallPrompt />
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<footer className="max-w-3xl mx-auto px-4 py-8 text-center">
|
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
ChronoMind — Press <kbd className="px-1 py-0.5 rounded text-xs font-mono" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>?</kbd> for shortcuts
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|