From 6cb006cc136f1fc5c83537cc30c64d223d1755e4 Mon Sep 17 00:00:00 2001 From: Solstice Date: Tue, 9 Jun 2026 00:22:18 -0700 Subject: feat: frontend view, state management, and user interface --- src/components/NotificationOverlay.tsx | 117 ++++++++++++++ src/components/SettingsPanel.tsx | 240 ++++++++++++++++++++++++++++ src/components/TaskList.tsx | 283 +++++++++++++++++++++++++++++++++ src/components/TimerView.tsx | 236 +++++++++++++++++++++++++++ 4 files changed, 876 insertions(+) create mode 100644 src/components/NotificationOverlay.tsx create mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/components/TaskList.tsx create mode 100644 src/components/TimerView.tsx (limited to 'src/components') 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 | 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 ( +
+
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', + }} + > + + Session complete + + {task && ( + + {task.name} + + )} + + Click to dismiss + +
+ + +
+ ); +} 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 ( +
+ {/* Modal card */} +
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', + }} + > + + Settings + + +
+ + + + +
+ +
+ + +
+
+
+ ); +} + +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 ( +
+ +
+ 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'; + }} + /> + + {unit} + +
+
+ ); +} 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 ( + + ); +} 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 ( +
+ {/* Eyebrow */} + + {eyebrowText(phase, sessionCount)} + + + {/* Circular ring */} +
+ + {/* Background track */} + + {/* Progress arc */} + + + + {/* Center content */} +
+ + {formatTime(remainingSecs)} + + + {phaseLabel(phase)} + +
+
+ + {/* Current task name */} + {currentTask && ( + + {currentTask.name} + + )} + + {/* Controls */} +
+ + + Skip + Reset +
+
+ ); +} + +function GhostButton({ + onClick, + children, +}: { + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} -- cgit v1.3-2-g0d8e