From 065bb1b1a0ff94249b9ba62a9fefda7f750fd07d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 21:45:37 -0800 Subject: [PATCH] feat(web): add focus mode UI and NL parser integration - FocusView component: duration presets, 'until next timer' mode, pomodoro shortcut, full-screen countdown ring, pause/resume, session summary with stats - Focus page at /focus with minimal header and back navigation - NL parser integrated into CreateTimerModal with live parse preview, Enter-to-create, and type/label/urgency detection display - Dashboard header: added Focus Mode (Eye icon) link --- web/src/app/focus/page.tsx | 50 ++++ web/src/components/CreateTimerModal.tsx | 93 +++++- web/src/components/Dashboard.tsx | 10 +- web/src/components/FocusView.tsx | 377 ++++++++++++++++++++++++ 4 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 web/src/app/focus/page.tsx create mode 100644 web/src/components/FocusView.tsx diff --git a/web/src/app/focus/page.tsx b/web/src/app/focus/page.tsx new file mode 100644 index 0000000..1edc7d0 --- /dev/null +++ b/web/src/app/focus/page.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { FocusView } from '@/components/FocusView'; +import { useTickLoop } from '@/lib/use-tick'; +import { Clock, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; + +export default function FocusPage() { + const router = useRouter(); + + // Keep the store ticking so "until next timer" works + useTickLoop(); + + return ( +
+ {/* Minimal header */} +
+
+ + + Back + +
+ + + ChronoMind Focus + +
+
{/* Spacer for centering */} +
+
+ + {/* Focus content */} +
+ router.push('/')} /> +
+
+ ); +} diff --git a/web/src/components/CreateTimerModal.tsx b/web/src/components/CreateTimerModal.tsx index bce8d9a..80160e2 100644 --- a/web/src/components/CreateTimerModal.tsx +++ b/web/src/components/CreateTimerModal.tsx @@ -6,7 +6,9 @@ import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency'; import type { UrgencyLevel } from '@/lib/urgency'; import { CASCADE_PRESET_LABELS } from '@/lib/cascade'; import type { CascadePreset } from '@/lib/cascade'; -import { X, AlarmClock, Timer, Coffee } from 'lucide-react'; +import { X, AlarmClock, Timer, Coffee, Sparkles } from 'lucide-react'; +import { parseNaturalLanguage } from '@/lib/nl-parser'; +import type { ParseResult } from '@/lib/nl-parser'; type TabType = 'alarm' | 'countdown' | 'pomodoro'; @@ -19,6 +21,8 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { const { addAlarm, addCountdown, addPomodoro } = useTimerStore(); const [tab, setTab] = useState('countdown'); + const [nlInput, setNlInput] = useState(''); + const [nlResult, setNlResult] = useState(null); const [label, setLabel] = useState(''); const [urgency, setUrgency] = useState('standard'); const [cascadePreset, setCascadePreset] = useState('standard'); @@ -39,6 +43,45 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { if (!isOpen) return null; + const handleNlChange = (value: string) => { + setNlInput(value); + if (value.trim()) { + setNlResult(parseNaturalLanguage(value)); + } else { + setNlResult(null); + } + }; + + const handleNlCreate = () => { + if (!nlResult?.success || !nlResult.timer) return; + const t = nlResult.timer; + if (t.type === 'countdown' && t.durationMs) { + addCountdown({ + label: t.label, + durationMs: t.durationMs, + urgency: t.urgency, + cascade: { preset: t.cascade, intervals: [] }, + }); + } else if (t.type === 'alarm' && t.targetTime) { + addAlarm({ + label: t.label, + targetTime: t.targetTime, + urgency: t.urgency, + cascade: { preset: t.cascade, intervals: [] }, + }); + } else if (t.type === 'pomodoro') { + addPomodoro({ + label: t.label, + config: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: t.pomodoroRounds ?? 4 }, + urgency: t.urgency, + }); + } + setNlInput(''); + setNlResult(null); + setLabel(''); + onClose(); + }; + const handleCreate = () => { const cascade = { preset: cascadePreset, intervals: [] as number[] }; @@ -120,6 +163,54 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { + {/* Natural Language Input */} +
+
+ + +
+
+ handleNlChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && nlResult?.success) handleNlCreate(); }} + placeholder='e.g. "meeting in 30 min" or "alarm at 3pm"' + className="flex-1 px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: nlResult?.success ? 'var(--cm-accent-secondary)' : 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> + {nlResult?.success && ( + + )} +
+ {nlResult && nlInput.trim() && ( +
+ {nlResult.success && nlResult.timer ? ( + + {nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'} + {' — '}{nlResult.timer.label} + {nlResult.timer.durationMs ? ` (${Math.round(nlResult.timer.durationMs / 60000)}m)` : ''} + {nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''} + + ) : ( + {nlResult.error} + )} +
+ )} +
+ {/* Tabs */}
{tabs.map((t) => ( diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 877cb0f..beb01f1 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -11,7 +11,7 @@ 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 } from 'lucide-react'; +import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye } from 'lucide-react'; import Link from 'next/link'; import { FeedbackButton } from './FeedbackButton'; import { useTheme } from '@/lib/use-theme'; @@ -135,6 +135,14 @@ export function Dashboard() { > {theme === 'dark' ? : } + + + void; +} + +export function FocusView({ onExit }: FocusViewProps) { + const [session, setSession] = useState(null); + const [now, setNow] = useState(Date.now()); + const rafRef = useRef(0); + const nextTimer = useTimerStore((s) => s.getNextFiringTimer()); + + // Tick loop for countdown + useEffect(() => { + if (!session?.isActive) return; + const tick = () => { + setNow(Date.now()); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + }, [session?.isActive]); + + const startFocus = useCallback((mode: FocusMode, durationMs: number) => { + setSession({ + mode, + durationMs, + startedAt: Date.now(), + pausedAt: null, + elapsedBeforePause: 0, + isActive: true, + isPaused: false, + isCompleted: false, + blockedCount: 0, + }); + }, []); + + const pauseFocus = useCallback(() => { + setSession((prev) => { + if (!prev || !prev.isActive || prev.isPaused) return prev; + const elapsed = prev.elapsedBeforePause + (Date.now() - prev.startedAt); + return { ...prev, isPaused: true, pausedAt: Date.now(), elapsedBeforePause: elapsed }; + }); + }, []); + + const resumeFocus = useCallback(() => { + setSession((prev) => { + if (!prev || !prev.isPaused) return prev; + return { ...prev, isPaused: false, pausedAt: null, startedAt: Date.now() }; + }); + }, []); + + const endFocus = useCallback(() => { + setSession((prev) => { + if (!prev) return prev; + return { ...prev, isActive: false, isCompleted: true }; + }); + }, []); + + // Auto-complete when duration is up + useEffect(() => { + if (!session?.isActive || session.isPaused || session.mode === 'until_next') return; + const elapsed = session.elapsedBeforePause + (now - session.startedAt); + if (elapsed >= session.durationMs) { + endFocus(); + } + }, [now, session, endFocus]); + + // Auto-complete "until next timer" when timer fires + useEffect(() => { + if (!session?.isActive || session.mode !== 'until_next') return; + if (!nextTimer) return; + if (now >= nextTimer.targetTime) { + endFocus(); + } + }, [now, session, nextTimer, endFocus]); + + // ── Not started: show setup ────────────────────────────────── + if (!session) { + const untilNextMs = nextTimer ? Math.max(0, nextTimer.targetTime - Date.now()) : 0; + + return ( +
+
+
+ +
+

+ Focus Mode +

+

+ Minimize distractions and stay in the zone. Only critical alerts will break through. +

+ + {/* Duration presets */} +
+

+ Choose duration +

+
+ {FOCUS_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Until next timer */} + {nextTimer && untilNextMs > 0 && ( + + )} + + {/* Pomodoro shortcut */} + +
+
+ ); + } + + // ── Completed: show summary ────────────────────────────────── + if (session.isCompleted) { + const totalElapsed = session.elapsedBeforePause + (session.isPaused ? 0 : (now - session.startedAt)); + const focusMinutes = Math.round(totalElapsed / 60_000); + + return ( +
+
+
+ +
+

+ Focus Complete! +

+

+ Great work staying focused. +

+ + {/* Stats */} +
+
+
+

+ {focusMinutes}m +

+

+ Time focused +

+
+
+

+ {session.blockedCount} +

+

+ Distractions blocked +

+
+
+
+
+ + + {session.mode === 'pomodoro' ? 'Pomodoro' : + session.mode === 'until_next' ? 'Until next timer' : + formatDurationCompact(session.durationMs)} focus session + +
+
+
+ +
+ + {onExit && ( + + )} +
+
+
+ ); + } + + // ── Active session: full-screen focus ──────────────────────── + const elapsed = session.isPaused + ? session.elapsedBeforePause + : session.elapsedBeforePause + (now - session.startedAt); + + const remaining = session.mode === 'until_next' + ? (nextTimer ? Math.max(0, nextTimer.targetTime - now) : 0) + : Math.max(0, session.durationMs - elapsed); + + const total = session.mode === 'until_next' + ? (nextTimer ? nextTimer.targetTime - session.startedAt : 1) + : session.durationMs; + + const progress = total > 0 ? Math.max(0, remaining / total) : 0; + + return ( +
+ {/* Shield badge */} +
+ + + Focus Active — Only critical alerts + +
+ +
+ {/* Countdown ring */} +
+ +
+
+ {formatDuration(remaining)} +
+
+ {session.isPaused ? 'Paused' : + session.mode === 'until_next' ? `Until ${nextTimer?.label ?? 'next timer'}` : + session.mode === 'pomodoro' ? 'Pomodoro Focus' : + 'Focus Time'} +
+
+
+
+ + {/* Controls */} +
+ {session.isPaused ? ( + + ) : ( + + )} + + +
+ + {/* Elapsed time */} +
+ Focused for {formatDurationCompact(elapsed)} +
+
+
+ ); +}