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
This commit is contained in:
saravanakumardb1 2026-02-27 21:45:37 -08:00
parent 8fe5e8e787
commit 065bb1b1a0
4 changed files with 528 additions and 2 deletions

View File

@ -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 (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
{/* Minimal 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">
<Link
href="/"
className="flex items-center gap-2 text-sm font-medium transition-colors"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<ArrowLeft size={16} />
Back
</Link>
<div className="flex items-center gap-2">
<Clock size={20} style={{ color: 'var(--cm-accent)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
ChronoMind Focus
</span>
</div>
<div className="w-16" /> {/* Spacer for centering */}
</div>
</header>
{/* Focus content */}
<main className="max-w-3xl mx-auto">
<FocusView onExit={() => router.push('/')} />
</main>
</div>
);
}

View File

@ -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<TabType>('countdown');
const [nlInput, setNlInput] = useState('');
const [nlResult, setNlResult] = useState<ParseResult | null>(null);
const [label, setLabel] = useState('');
const [urgency, setUrgency] = useState<UrgencyLevel>('standard');
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('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) {
</button>
</div>
{/* Natural Language Input */}
<div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
<div className="flex items-center gap-2 mb-1">
<Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} />
<label className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}>
Quick create type naturally
</label>
</div>
<div className="flex gap-2">
<input
type="text"
value={nlInput}
onChange={(e) => 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 && (
<button
onClick={handleNlCreate}
className="px-4 py-2 rounded-lg text-xs font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent-secondary)', color: '#000' }}
>
Create
</button>
)}
</div>
{nlResult && nlInput.trim() && (
<div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-tertiary)' }}>
{nlResult.success && nlResult.timer ? (
<span>
{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}]` : ''}
</span>
) : (
<span style={{ color: 'var(--cm-text-tertiary)' }}>{nlResult.error}</span>
)}
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b" style={{ borderColor: 'var(--cm-border)' }}>
{tabs.map((t) => (

View File

@ -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' ? <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"
>
<Eye size={18} />
</Link>
<Link
href="/settings"
className="p-2 rounded-lg transition-colors"

View File

@ -0,0 +1,377 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { CountdownRing } from './CountdownRing';
import { formatDuration, formatDurationCompact } from '@/lib/format';
import { useTimerStore } from '@/lib/store';
import {
Eye,
Pause,
Play,
X,
Trophy,
Clock,
Shield,
Zap,
} from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────
export type FocusMode = 'duration' | 'until_next' | 'pomodoro';
export interface FocusSession {
mode: FocusMode;
durationMs: number; // total duration in ms (0 if 'until_next')
startedAt: number;
pausedAt: number | null;
elapsedBeforePause: number;
isActive: boolean;
isPaused: boolean;
isCompleted: boolean;
blockedCount: number; // notifications blocked during session
}
// ── Duration Presets ───────────────────────────────────────────
const FOCUS_PRESETS = [
{ label: '15m', ms: 15 * 60_000 },
{ label: '25m', ms: 25 * 60_000 },
{ label: '45m', ms: 45 * 60_000 },
{ label: '1h', ms: 60 * 60_000 },
{ label: '90m', ms: 90 * 60_000 },
{ label: '2h', ms: 120 * 60_000 },
];
// ── Component ──────────────────────────────────────────────────
interface FocusViewProps {
onExit?: () => void;
}
export function FocusView({ onExit }: FocusViewProps) {
const [session, setSession] = useState<FocusSession | null>(null);
const [now, setNow] = useState(Date.now());
const rafRef = useRef<number>(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 (
<div className="min-h-[80vh] flex flex-col items-center justify-center px-4">
<div className="text-center max-w-md w-full">
<div
className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: 'rgba(90, 140, 255, 0.12)' }}
>
<Eye size={40} style={{ color: 'var(--cm-accent)' }} />
</div>
<h2
className="text-2xl font-bold mb-2"
style={{ color: 'var(--cm-text-primary)' }}
>
Focus Mode
</h2>
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}>
Minimize distractions and stay in the zone. Only critical alerts will break through.
</p>
{/* Duration presets */}
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
Choose duration
</p>
<div className="grid grid-cols-3 gap-2">
{FOCUS_PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() => startFocus('duration', preset.ms)}
className="py-3 rounded-xl text-sm font-medium transition-all cursor-pointer hover:scale-105"
style={{
backgroundColor: 'var(--cm-surface-card)',
color: 'var(--cm-text-primary)',
border: '1px solid var(--cm-border)',
}}
>
{preset.label}
</button>
))}
</div>
</div>
{/* Until next timer */}
{nextTimer && untilNextMs > 0 && (
<button
onClick={() => startFocus('until_next', untilNextMs)}
className="w-full py-3 rounded-xl text-sm font-medium transition-all cursor-pointer mb-4"
style={{
backgroundColor: 'rgba(46, 230, 214, 0.1)',
color: 'var(--cm-accent-secondary)',
border: '1px solid rgba(46, 230, 214, 0.3)',
}}
>
<div className="flex items-center justify-center gap-2">
<Clock size={16} />
Until next timer ({formatDurationCompact(untilNextMs)})
</div>
<div className="text-xs mt-1 opacity-70">
{nextTimer.label}
</div>
</button>
)}
{/* Pomodoro shortcut */}
<button
onClick={() => startFocus('pomodoro', 25 * 60_000)}
className="w-full py-3 rounded-xl text-sm font-medium transition-all cursor-pointer"
style={{
backgroundColor: 'rgba(90, 140, 255, 0.1)',
color: 'var(--cm-accent)',
border: '1px solid rgba(90, 140, 255, 0.3)',
}}
>
<div className="flex items-center justify-center gap-2">
<Zap size={16} />
Pomodoro Focus (25m work)
</div>
</button>
</div>
</div>
);
}
// ── Completed: show summary ──────────────────────────────────
if (session.isCompleted) {
const totalElapsed = session.elapsedBeforePause + (session.isPaused ? 0 : (now - session.startedAt));
const focusMinutes = Math.round(totalElapsed / 60_000);
return (
<div className="min-h-[80vh] flex flex-col items-center justify-center px-4">
<div className="text-center max-w-md w-full">
<div
className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: 'rgba(52, 211, 153, 0.12)' }}
>
<Trophy size={40} style={{ color: 'var(--cm-success)' }} />
</div>
<h2
className="text-2xl font-bold mb-2"
style={{ color: 'var(--cm-success)' }}
>
Focus Complete!
</h2>
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}>
Great work staying focused.
</p>
{/* Stats */}
<div
className="rounded-2xl border p-6 mb-6"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold font-mono" style={{ color: 'var(--cm-text-primary)' }}>
{focusMinutes}m
</p>
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Time focused
</p>
</div>
<div>
<p className="text-2xl font-bold font-mono" style={{ color: 'var(--cm-text-primary)' }}>
{session.blockedCount}
</p>
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Distractions blocked
</p>
</div>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: 'var(--cm-border)' }}>
<div className="flex items-center justify-center gap-2 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
<Shield size={14} />
<span>
{session.mode === 'pomodoro' ? 'Pomodoro' :
session.mode === 'until_next' ? 'Until next timer' :
formatDurationCompact(session.durationMs)} focus session
</span>
</div>
</div>
</div>
<div className="flex gap-3 justify-center">
<button
onClick={() => setSession(null)}
className="px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
Start Another
</button>
{onExit && (
<button
onClick={onExit}
className="px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
Back to Dashboard
</button>
)}
</div>
</div>
</div>
);
}
// ── 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 (
<div
className="min-h-[80vh] flex flex-col items-center justify-center px-4 relative"
>
{/* Shield badge */}
<div className="absolute top-6 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full"
style={{ backgroundColor: 'rgba(90, 140, 255, 0.08)', border: '1px solid rgba(90, 140, 255, 0.2)' }}
>
<Shield size={14} style={{ color: 'var(--cm-accent)' }} />
<span className="text-xs font-medium" style={{ color: 'var(--cm-accent)' }}>
Focus Active Only critical alerts
</span>
</div>
<div className="text-center">
{/* Countdown ring */}
<div className="flex justify-center mb-6">
<CountdownRing progress={progress} size={260} strokeWidth={12} color="var(--cm-accent)">
<div className="text-center">
<div
className="text-5xl font-mono font-bold tabular-nums"
style={{ color: 'var(--cm-text-primary)' }}
>
{formatDuration(remaining)}
</div>
<div className="text-sm mt-2" style={{ color: 'var(--cm-text-tertiary)' }}>
{session.isPaused ? 'Paused' :
session.mode === 'until_next' ? `Until ${nextTimer?.label ?? 'next timer'}` :
session.mode === 'pomodoro' ? 'Pomodoro Focus' :
'Focus Time'}
</div>
</div>
</CountdownRing>
</div>
{/* Controls */}
<div className="flex justify-center gap-3">
{session.isPaused ? (
<button
onClick={resumeFocus}
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Play size={16} /> Resume
</button>
) : (
<button
onClick={pauseFocus}
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
<Pause size={16} /> Pause
</button>
)}
<button
onClick={endFocus}
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
>
<X size={16} /> End Session
</button>
</div>
{/* Elapsed time */}
<div className="mt-6 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Focused for {formatDurationCompact(elapsed)}
</div>
</div>
</div>
);
}