diff options
| author | Solstice <solstice@local> | 2026-06-09 00:22:18 -0700 |
|---|---|---|
| committer | Solstice <solstice@local> | 2026-06-09 00:22:18 -0700 |
| commit | 6cb006cc136f1fc5c83537cc30c64d223d1755e4 (patch) | |
| tree | b12dfb4a8345c980dc8747657ce381016cdf3b34 /src | |
| parent | 25e1dcf205cd14feafdd9b4cf6b7a66f253ba6d2 (diff) | |
feat: frontend view, state management, and user interface
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.tsx | 113 | ||||
| -rw-r--r-- | src/components/NotificationOverlay.tsx | 117 | ||||
| -rw-r--r-- | src/components/SettingsPanel.tsx | 240 | ||||
| -rw-r--r-- | src/components/TaskList.tsx | 283 | ||||
| -rw-r--r-- | src/components/TimerView.tsx | 236 | ||||
| -rw-r--r-- | src/hooks/useTimerEvents.ts | 78 | ||||
| -rw-r--r-- | src/store/settingsStore.ts | 33 | ||||
| -rw-r--r-- | src/store/taskStore.ts | 80 | ||||
| -rw-r--r-- | src/store/timerStore.ts | 42 |
9 files changed, 1217 insertions, 5 deletions
diff --git a/src/App.tsx b/src/App.tsx index a6e3249..966e8e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,114 @@ +import { useEffect, useState, useCallback } from 'react'; +import { TimerView } from './components/TimerView'; +import { TaskList } from './components/TaskList'; +import { SettingsPanel } from './components/SettingsPanel'; +import { NotificationOverlay } from './components/NotificationOverlay'; +import { useTimerEvents } from './hooks/useTimerEvents'; +import { useTaskStore } from './store/taskStore'; +import { useSettingsStore } from './store/settingsStore'; + +function GearIcon() { + return ( + <svg + width="18" + height="18" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="3" /> + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> + </svg> + ); +} + function App() { + const [settingsOpen, setSettingsOpen] = useState(false); + const [notifVisible, setNotifVisible] = useState(false); + const [notifTaskId, setNotifTaskId] = useState<string | null>(null); + + const fetchTasks = useTaskStore((s) => s.fetchTasks); + const fetchSettings = useSettingsStore((s) => s.fetchSettings); + + // Bootstrap data on mount + useEffect(() => { + fetchTasks(); + fetchSettings(); + }, [fetchTasks, fetchSettings]); + + const handleCompleted = useCallback((taskId: string | null) => { + setNotifTaskId(taskId); + setNotifVisible(true); + }, []); + + useTimerEvents(handleCompleted); + return ( - <main className="flex items-center justify-center min-h-screen"> - <div className="text-center"> - <h1 className="font-display text-fg-1 text-4xl mb-2">Solstice</h1> - <p className="text-fg-3">Pomodoro timer with ambient soundscapes.</p> + <div + style={{ + display: 'flex', + width: '100vw', + height: '100vh', + background: 'var(--ink-0)', + overflow: 'hidden', + position: 'relative', + }} + > + {/* Sidebar */} + <TaskList /> + + {/* Main area */} + <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> + {/* Top bar */} + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + padding: '12px 16px', + borderBottom: '1px solid var(--line-1)', + }} + > + <button + onClick={() => setSettingsOpen(true)} + title="Settings" + style={{ + display: 'flex', + alignItems: 'center', + padding: '6px', + border: 'none', + background: 'transparent', + color: 'var(--fg-3)', + cursor: 'pointer', + borderRadius: 'var(--r-2)', + transition: 'color 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-1)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-3)'; + }} + > + <GearIcon /> + </button> + </div> + + {/* Timer */} + <TimerView /> </div> - </main> + + {/* Overlays */} + <SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} /> + <NotificationOverlay + visible={notifVisible} + taskId={notifTaskId} + onDismiss={() => setNotifVisible(false)} + /> + </div> ); } diff --git a/src/components/NotificationOverlay.tsx b/src/components/NotificationOverlay.tsx new file mode 100644 index 0000000..965f19d --- /dev/null +++ b/src/components/NotificationOverlay.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef } from 'react'; +import { sendNotification } from '@tauri-apps/plugin-notification'; +import { useTaskStore } from '../store/taskStore'; + +interface NotificationOverlayProps { + visible: boolean; + taskId: string | null; + onDismiss: () => void; +} + +export function NotificationOverlay({ visible, taskId, onDismiss }: NotificationOverlayProps) { + const tasks = useTaskStore((s) => s.tasks); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const task = tasks.find((t) => t.id === taskId) ?? null; + + useEffect(() => { + if (!visible) return; + + // OS notification + const body = task ? `"${task.name}" session complete.` : 'Session complete. Time for a break.'; + try { + sendNotification({ title: 'Solstice', body }); + } catch { + // Notifications may not be permitted in dev; ignore errors + } + + // Auto-dismiss after 4 seconds + timerRef.current = setTimeout(() => { + onDismiss(); + }, 4000); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [visible, task, onDismiss]); + + if (!visible) return null; + + return ( + <div + onClick={onDismiss} + style={{ + position: 'fixed', + inset: 0, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + paddingTop: '40px', + zIndex: 200, + pointerEvents: 'auto', + }} + > + <div + onClick={(e) => e.stopPropagation()} + style={{ + background: 'var(--ink-2)', + border: '1px solid var(--line-2)', + borderRadius: 'var(--r-3)', + boxShadow: 'var(--shadow-3)', + padding: '24px 32px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8px', + animation: 'slideDown 0.2s ease', + minWidth: '280px', + }} + > + <span + style={{ + fontFamily: 'var(--font-display)', + fontStyle: 'italic', + fontSize: '28px', + fontWeight: 400, + color: 'var(--fg-1)', + lineHeight: 1.1, + }} + > + Session complete + </span> + {task && ( + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '13px', + color: 'var(--fg-3)', + }} + > + {task.name} + </span> + )} + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '11px', + color: 'var(--fg-4)', + marginTop: '4px', + textTransform: 'uppercase', + letterSpacing: '0.1em', + }} + > + Click to dismiss + </span> + </div> + + <style>{` + @keyframes slideDown { + from { opacity: 0; transform: translateY(-16px); } + to { opacity: 1; transform: translateY(0); } + } + `}</style> + </div> + ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..0d3cbf9 --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from 'react'; +import { useSettingsStore, Settings } from '../store/settingsStore'; + +interface SettingsPanelProps { + open: boolean; + onClose: () => void; +} + +export function SettingsPanel({ open, onClose }: SettingsPanelProps) { + const { settings, fetchSettings, updateSettings } = useSettingsStore(); + + const [workMins, setWorkMins] = useState(25); + const [shortBreakMins, setShortBreakMins] = useState(5); + const [longBreakMins, setLongBreakMins] = useState(15); + const [sessionsBeforeLong, setSessionsBeforeLong] = useState(4); + + useEffect(() => { + if (open && !settings) { + fetchSettings(); + } + }, [open, settings, fetchSettings]); + + useEffect(() => { + if (settings) { + setWorkMins(Math.round(settings.work_duration_secs / 60)); + setShortBreakMins(Math.round(settings.short_break_secs / 60)); + setLongBreakMins(Math.round(settings.long_break_secs / 60)); + setSessionsBeforeLong(settings.sessions_before_long_break); + } + }, [settings]); + + if (!open) return null; + + const handleSave = async () => { + const s: Settings = { + work_duration_secs: workMins * 60, + short_break_secs: shortBreakMins * 60, + long_break_secs: longBreakMins * 60, + sessions_before_long_break: sessionsBeforeLong, + }; + await updateSettings(s); + onClose(); + }; + + return ( + <div + onClick={onClose} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(11, 14, 19, 0.70)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + }} + > + {/* Modal card */} + <div + onClick={(e) => e.stopPropagation()} + style={{ + background: 'var(--ink-1)', + border: '1px solid var(--line-2)', + borderRadius: 'var(--r-3)', + boxShadow: 'var(--shadow-3)', + padding: '32px', + width: '360px', + display: 'flex', + flexDirection: 'column', + gap: '24px', + }} + > + <span className="eyebrow" style={{ letterSpacing: '0.18em' }}> + Settings + </span> + + <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> + <NumberField + label="Work duration" + unit="min" + value={workMins} + min={1} + max={120} + onChange={setWorkMins} + /> + <NumberField + label="Short break" + unit="min" + value={shortBreakMins} + min={1} + max={60} + onChange={setShortBreakMins} + /> + <NumberField + label="Long break" + unit="min" + value={longBreakMins} + min={1} + max={60} + onChange={setLongBreakMins} + /> + <NumberField + label="Sessions before long break" + unit="sessions" + value={sessionsBeforeLong} + min={1} + max={10} + onChange={setSessionsBeforeLong} + /> + </div> + + <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}> + <button + onClick={onClose} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + fontWeight: 500, + padding: '8px 16px', + borderRadius: 'var(--r-1)', + border: '1px solid var(--line-2)', + background: 'transparent', + color: 'var(--brass)', + cursor: 'pointer', + transition: 'border-color 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--brass)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--line-2)'; + }} + > + Cancel + </button> + <button + onClick={handleSave} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + fontWeight: 600, + padding: '8px 20px', + borderRadius: 'var(--r-1)', + border: 'none', + background: 'var(--brass)', + color: 'var(--brass-ink)', + cursor: 'pointer', + transition: 'background 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)'; + }} + onMouseDown={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-deep)'; + }} + onMouseUp={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + > + Save + </button> + </div> + </div> + </div> + ); +} + +interface NumberFieldProps { + label: string; + unit: string; + value: number; + min: number; + max: number; + onChange: (v: number) => void; +} + +function NumberField({ label, unit, value, min, max, onChange }: NumberFieldProps) { + return ( + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '16px', + }} + > + <label + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + color: 'var(--fg-2)', + flex: 1, + }} + > + {label} + </label> + <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> + <input + type="number" + min={min} + max={max} + value={value} + onChange={(e) => onChange(Math.min(max, Math.max(min, parseInt(e.target.value) || min)))} + style={{ + fontFamily: 'var(--font-mono)', + fontSize: '14px', + fontVariantNumeric: 'tabular-nums', + padding: '5px 8px', + borderRadius: 'var(--r-2)', + border: '1px solid var(--line-2)', + background: 'var(--ink-3)', + color: 'var(--fg-1)', + outline: 'none', + width: '56px', + textAlign: 'center', + }} + onFocus={(e) => { + (e.currentTarget as HTMLInputElement).style.boxShadow = 'var(--shadow-brass)'; + }} + onBlur={(e) => { + (e.currentTarget as HTMLInputElement).style.boxShadow = 'none'; + }} + /> + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + color: 'var(--fg-4)', + minWidth: '44px', + }} + > + {unit} + </span> + </div> + </div> + ); +} diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx new file mode 100644 index 0000000..0f87e0d --- /dev/null +++ b/src/components/TaskList.tsx @@ -0,0 +1,283 @@ +import { useState } from 'react'; +import { useTaskStore, Task } from '../store/taskStore'; +import { useTimerStore } from '../store/timerStore'; + +export function TaskList() { + const { tasks, addTask, deleteTask, setCurrentTask } = useTaskStore(); + const currentTaskId = useTimerStore((s) => s.currentTaskId); + + const [showForm, setShowForm] = useState(false); + const [newName, setNewName] = useState(''); + const [newSessions, setNewSessions] = useState(4); + + const handleAdd = async () => { + const name = newName.trim(); + if (!name) return; + await addTask(name, newSessions); + setNewName(''); + setNewSessions(4); + setShowForm(false); + }; + + const handleSelect = async (task: Task) => { + if (task.id === currentTaskId) { + await setCurrentTask(null); + } else { + await setCurrentTask(task.id); + } + }; + + const handleDelete = async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + await deleteTask(id); + }; + + return ( + <aside + style={{ + width: '280px', + flexShrink: 0, + background: 'var(--ink-1)', + borderRight: '1px solid var(--line-1)', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }} + > + {/* Header */} + <div + style={{ + padding: '16px', + borderBottom: '1px solid var(--line-1)', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > + <span className="eyebrow">Tasks</span> + <button + onClick={() => setShowForm((v) => !v)} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + fontWeight: 600, + padding: '4px 10px', + borderRadius: 'var(--r-1)', + border: '1px solid var(--line-2)', + background: 'transparent', + color: 'var(--brass)', + cursor: 'pointer', + transition: 'border-color 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--brass)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--line-2)'; + }} + > + + Add + </button> + </div> + + {/* Add task form */} + {showForm && ( + <div + style={{ + padding: '12px 16px', + borderBottom: '1px solid var(--line-1)', + display: 'flex', + flexDirection: 'column', + gap: '8px', + }} + > + <input + type="text" + placeholder="Task name" + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + autoFocus + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + padding: '6px 10px', + borderRadius: 'var(--r-2)', + border: '1px solid var(--line-2)', + background: 'var(--ink-3)', + color: 'var(--fg-1)', + outline: 'none', + width: '100%', + boxSizing: 'border-box', + }} + onFocus={(e) => { + (e.currentTarget as HTMLInputElement).style.boxShadow = 'var(--shadow-brass)'; + }} + onBlur={(e) => { + (e.currentTarget as HTMLInputElement).style.boxShadow = 'none'; + }} + /> + <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> + <label + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + color: 'var(--fg-3)', + whiteSpace: 'nowrap', + }} + > + Sessions + </label> + <input + type="number" + min={1} + max={20} + value={newSessions} + onChange={(e) => setNewSessions(Math.max(1, parseInt(e.target.value) || 1))} + style={{ + fontFamily: 'var(--font-mono)', + fontSize: '14px', + padding: '4px 8px', + borderRadius: 'var(--r-2)', + border: '1px solid var(--line-2)', + background: 'var(--ink-3)', + color: 'var(--fg-1)', + outline: 'none', + width: '56px', + textAlign: 'center', + }} + /> + <button + onClick={handleAdd} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + fontWeight: 600, + padding: '4px 12px', + borderRadius: 'var(--r-1)', + border: 'none', + background: 'var(--brass)', + color: 'var(--brass-ink)', + cursor: 'pointer', + marginLeft: 'auto', + transition: 'background 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)'; + }} + > + Save + </button> + </div> + </div> + )} + + {/* Task list */} + <div style={{ overflowY: 'auto', flex: 1 }}> + {tasks.length === 0 && ( + <div + style={{ + padding: '24px 16px', + textAlign: 'center', + fontFamily: 'var(--font-sans)', + fontSize: '13px', + color: 'var(--fg-4)', + }} + > + No tasks yet + </div> + )} + {tasks.map((task) => { + const isSelected = task.id === currentTaskId; + const isCompleted = task.completed; + + return ( + <div + key={task.id} + onClick={() => !isCompleted && handleSelect(task)} + style={{ + padding: '10px 16px', + borderBottom: '1px solid var(--line-1)', + borderLeft: isSelected ? '2px solid var(--brass)' : '2px solid transparent', + display: 'flex', + alignItems: 'center', + gap: '8px', + cursor: isCompleted ? 'default' : 'pointer', + background: isSelected ? 'var(--ink-2)' : 'transparent', + opacity: isCompleted ? 0.45 : 1, + transition: 'background 0.15s ease, border-color 0.15s ease', + }} + onMouseEnter={(e) => { + if (!isSelected && !isCompleted) { + (e.currentTarget as HTMLDivElement).style.background = 'var(--ink-2)'; + } + }} + onMouseLeave={(e) => { + if (!isSelected) { + (e.currentTarget as HTMLDivElement).style.background = 'transparent'; + } + }} + > + {/* Task name */} + <div style={{ flex: 1, minWidth: 0 }}> + <div + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + color: isCompleted ? 'var(--fg-4)' : 'var(--fg-2)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {task.name} + </div> + {/* Session count */} + <div + style={{ + fontFamily: 'var(--font-mono)', + fontSize: '11px', + color: 'var(--fg-4)', + fontVariantNumeric: 'tabular-nums', + marginTop: '2px', + }} + > + {task.remaining_sessions} / {task.total_sessions} + </div> + </div> + + {/* Delete */} + <button + onClick={(e) => handleDelete(e, task.id)} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '16px', + lineHeight: 1, + padding: '2px 4px', + border: 'none', + background: 'transparent', + color: 'var(--fg-4)', + cursor: 'pointer', + flexShrink: 0, + transition: 'color 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.color = 'var(--negative)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.color = 'var(--fg-4)'; + }} + title="Remove task" + > + x + </button> + </div> + ); + })} + </div> + </aside> + ); +} diff --git a/src/components/TimerView.tsx b/src/components/TimerView.tsx new file mode 100644 index 0000000..b56e6a8 --- /dev/null +++ b/src/components/TimerView.tsx @@ -0,0 +1,236 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useTimerStore, TimerPhase } from '../store/timerStore'; +import { useTaskStore } from '../store/taskStore'; + +const RING_SIZE = 280; +const STROKE = 8; +const RADIUS = (RING_SIZE - STROKE) / 2; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +function phaseLabel(phase: TimerPhase): string { + switch (phase) { + case 'work': return 'Focus'; + case 'short_break': return 'Short Break'; + case 'long_break': return 'Long Break'; + } +} + +function phaseColor(phase: TimerPhase): string { + switch (phase) { + case 'work': return 'var(--brass)'; + case 'short_break': return 'var(--positive)'; + case 'long_break': return 'var(--info)'; + } +} + +function eyebrowText(phase: TimerPhase, sessionCount: number): string { + if (phase === 'work') { + return `WORK ยท SESSION ${sessionCount + 1}`; + } + if (phase === 'short_break') return 'SHORT BREAK'; + return 'LONG BREAK'; +} + +function formatTime(secs: number): string { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +export function TimerView() { + const { phase, remainingSecs, totalSecs, running, sessionCount, currentTaskId } = + useTimerStore(); + const tasks = useTaskStore((s) => s.tasks); + + const currentTask = tasks.find((t) => t.id === currentTaskId) ?? null; + + const progress = totalSecs > 0 ? remainingSecs / totalSecs : 1; + const dashOffset = CIRCUMFERENCE * (1 - progress); + const arcColor = phaseColor(phase); + + const handleStart = () => invoke('start_timer'); + const handlePause = () => invoke('pause_timer'); + const handleSkip = () => invoke('skip_phase'); + const handleReset = () => invoke('reset_timer'); + + return ( + <div + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '24px', + flex: 1, + padding: '32px', + }} + > + {/* Eyebrow */} + <span className="eyebrow" style={{ letterSpacing: '0.18em' }}> + {eyebrowText(phase, sessionCount)} + </span> + + {/* Circular ring */} + <div style={{ position: 'relative', width: RING_SIZE, height: RING_SIZE }}> + <svg + width={RING_SIZE} + height={RING_SIZE} + style={{ transform: 'rotate(-90deg)' }} + > + {/* Background track */} + <circle + cx={RING_SIZE / 2} + cy={RING_SIZE / 2} + r={RADIUS} + fill="none" + stroke="var(--line-2)" + strokeWidth={STROKE} + /> + {/* Progress arc */} + <circle + cx={RING_SIZE / 2} + cy={RING_SIZE / 2} + r={RADIUS} + fill="none" + stroke={arcColor} + strokeWidth={STROKE} + strokeLinecap="round" + strokeDasharray={CIRCUMFERENCE} + strokeDashoffset={dashOffset} + style={{ transition: 'stroke-dashoffset 0.8s ease, stroke 0.3s ease' }} + /> + </svg> + + {/* Center content */} + <div + style={{ + position: 'absolute', + inset: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + }} + > + <span + style={{ + fontFamily: 'var(--font-display)', + fontStyle: 'italic', + fontSize: '56px', + fontWeight: 400, + lineHeight: 1, + color: 'var(--fg-1)', + letterSpacing: '-0.02em', + fontVariantNumeric: 'tabular-nums', + }} + > + {formatTime(remainingSecs)} + </span> + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.12em', + color: 'var(--fg-3)', + }} + > + {phaseLabel(phase)} + </span> + </div> + </div> + + {/* Current task name */} + {currentTask && ( + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + color: 'var(--fg-3)', + maxWidth: '320px', + textAlign: 'center', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {currentTask.name} + </span> + )} + + {/* Controls */} + <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> + <button + onClick={running ? handlePause : handleStart} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + fontWeight: 600, + padding: '8px 24px', + borderRadius: 'var(--r-1)', + border: 'none', + background: 'var(--brass)', + color: 'var(--brass-ink)', + cursor: 'pointer', + transition: 'background 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass)'; + }} + onMouseDown={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-deep)'; + }} + onMouseUp={(e) => { + (e.currentTarget as HTMLButtonElement).style.background = 'var(--brass-bright)'; + }} + > + {running ? 'Pause' : 'Start'} + </button> + + <GhostButton onClick={handleSkip}>Skip</GhostButton> + <GhostButton onClick={handleReset}>Reset</GhostButton> + </div> + </div> + ); +} + +function GhostButton({ + onClick, + children, +}: { + onClick: () => void; + children: React.ReactNode; +}) { + return ( + <button + onClick={onClick} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '14px', + fontWeight: 500, + padding: '8px 16px', + borderRadius: 'var(--r-1)', + border: '1px solid var(--line-2)', + background: 'transparent', + color: 'var(--brass)', + cursor: 'pointer', + transition: 'border-color 0.15s ease, color 0.15s ease', + }} + onMouseEnter={(e) => { + const el = e.currentTarget as HTMLButtonElement; + el.style.borderColor = 'var(--brass)'; + }} + onMouseLeave={(e) => { + const el = e.currentTarget as HTMLButtonElement; + el.style.borderColor = 'var(--line-2)'; + }} + > + {children} + </button> + ); +} diff --git a/src/hooks/useTimerEvents.ts b/src/hooks/useTimerEvents.ts new file mode 100644 index 0000000..2bc502d --- /dev/null +++ b/src/hooks/useTimerEvents.ts @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; +import { useTimerStore, TimerTickPayload } from '../store/timerStore'; +import { useTaskStore } from '../store/taskStore'; + +interface PhaseChangedPayload { + phase: TimerTickPayload['phase']; + session_count: number; +} + +interface CompletedPayload { + task_id: string | null; +} + +export function useTimerEvents( + onCompleted: (taskId: string | null) => void, +) { + const setTimerTick = useTimerStore((s) => s.setTimerTick); + const setRunning = useTimerStore((s) => s.setRunning); + const fetchTasks = useTaskStore((s) => s.fetchTasks); + + useEffect(() => { + // Bootstrap initial state from backend + invoke<{ + phase: TimerTickPayload['phase']; + remaining_secs: number; + total_secs: number; + running: boolean; + session_count: number; + current_task_id: string | null; + }>('get_timer_status') + .then((status) => { + setTimerTick({ + phase: status.phase, + remaining_secs: status.remaining_secs, + total_secs: status.total_secs, + session_count: status.session_count, + current_task_id: status.current_task_id, + }); + setRunning(status.running); + }) + .catch((e) => console.error('get_timer_status error:', e)); + + let unlistenTick: (() => void) | null = null; + let unlistenCompleted: (() => void) | null = null; + let unlistenPhaseChanged: (() => void) | null = null; + + listen<TimerTickPayload>('timer-tick', (event) => { + setTimerTick(event.payload); + setRunning(true); + }).then((fn) => { unlistenTick = fn; }); + + listen<CompletedPayload>('timer-completed', (event) => { + setRunning(false); + onCompleted(event.payload.task_id); + // Refresh tasks since remaining_sessions may have changed + fetchTasks(); + }).then((fn) => { unlistenCompleted = fn; }); + + listen<PhaseChangedPayload>('timer-phase-changed', (event) => { + setRunning(false); + setTimerTick({ + phase: event.payload.phase, + remaining_secs: useTimerStore.getState().remainingSecs, + total_secs: useTimerStore.getState().totalSecs, + session_count: event.payload.session_count, + current_task_id: useTimerStore.getState().currentTaskId, + }); + }).then((fn) => { unlistenPhaseChanged = fn; }); + + return () => { + unlistenTick?.(); + unlistenCompleted?.(); + unlistenPhaseChanged?.(); + }; + }, [setTimerTick, setRunning, fetchTasks, onCompleted]); +} diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts new file mode 100644 index 0000000..ea7be34 --- /dev/null +++ b/src/store/settingsStore.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand'; +import { invoke } from '@tauri-apps/api/core'; + +export interface Settings { + work_duration_secs: number; + short_break_secs: number; + long_break_secs: number; + sessions_before_long_break: number; +} + +interface SettingsStore { + settings: Settings | null; + fetchSettings: () => Promise<void>; + updateSettings: (s: Settings) => Promise<void>; +} + +export const useSettingsStore = create<SettingsStore>((set) => ({ + settings: null, + + fetchSettings: async () => { + try { + const settings = await invoke<Settings>('get_settings'); + set({ settings }); + } catch (e) { + console.error('fetchSettings error:', e); + } + }, + + updateSettings: async (s) => { + await invoke('update_settings', { settings: s }); + set({ settings: s }); + }, +})); diff --git a/src/store/taskStore.ts b/src/store/taskStore.ts new file mode 100644 index 0000000..dccd17d --- /dev/null +++ b/src/store/taskStore.ts @@ -0,0 +1,80 @@ +import { create } from 'zustand'; +import { invoke } from '@tauri-apps/api/core'; + +export interface Task { + id: string; + name: string; + total_sessions: number; + remaining_sessions: number; + completed: boolean; + created_at: string; +} + +interface TaskStore { + tasks: Task[]; + loading: boolean; + fetchTasks: () => Promise<void>; + addTask: (name: string, totalSessions: number) => Promise<void>; + updateTask: (id: string, remainingSessions?: number, completed?: boolean) => Promise<void>; + deleteTask: (id: string) => Promise<void>; + setCurrentTask: (id: string | null) => Promise<void>; + refreshTask: (task: Task) => void; +} + +export const useTaskStore = create<TaskStore>((set) => ({ + tasks: [], + loading: false, + + fetchTasks: async () => { + set({ loading: true }); + try { + const tasks = await invoke<Task[]>('get_tasks'); + set({ tasks, loading: false }); + } catch (e) { + console.error('fetchTasks error:', e); + set({ loading: false }); + } + }, + + addTask: async (name, totalSessions) => { + const task = await invoke<Task>('add_task', { + name, + totalSessions, + }); + set((state) => ({ tasks: [...state.tasks, task] })); + }, + + updateTask: async (id, remainingSessions, completed) => { + await invoke('update_task', { + id, + remainingSessions: remainingSessions ?? null, + completed: completed ?? null, + }); + set((state) => ({ + tasks: state.tasks.map((t) => + t.id === id + ? { + ...t, + remaining_sessions: remainingSessions ?? t.remaining_sessions, + completed: completed ?? t.completed, + } + : t, + ), + })); + }, + + deleteTask: async (id) => { + await invoke('delete_task', { id }); + set((state) => ({ tasks: state.tasks.filter((t) => t.id !== id) })); + }, + + setCurrentTask: async (id) => { + await invoke('set_current_task', { taskId: id }); + }, + + refreshTask: (updatedTask) => { + set((state) => ({ + tasks: state.tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)), + })); + }, +})); diff --git a/src/store/timerStore.ts b/src/store/timerStore.ts new file mode 100644 index 0000000..6a81e20 --- /dev/null +++ b/src/store/timerStore.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; + +export type TimerPhase = 'work' | 'short_break' | 'long_break'; + +export interface TimerTickPayload { + phase: TimerPhase; + remaining_secs: number; + total_secs: number; + session_count: number; + current_task_id: string | null; +} + +interface TimerState { + phase: TimerPhase; + remainingSecs: number; + totalSecs: number; + running: boolean; + sessionCount: number; + currentTaskId: string | null; + setTimerTick: (payload: TimerTickPayload) => void; + setRunning: (running: boolean) => void; +} + +export const useTimerStore = create<TimerState>((set) => ({ + phase: 'work', + remainingSecs: 25 * 60, + totalSecs: 25 * 60, + running: false, + sessionCount: 0, + currentTaskId: null, + + setTimerTick: (payload) => + set({ + phase: payload.phase, + remainingSecs: payload.remaining_secs, + totalSecs: payload.total_secs, + sessionCount: payload.session_count, + currentTaskId: payload.current_task_id, + }), + + setRunning: (running) => set({ running }), +})); |
