learning_ai_clock/web/src/components/Dashboard.tsx

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>
);
}