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/App.tsx | 113 ++++++++++++- src/components/NotificationOverlay.tsx | 117 ++++++++++++++ src/components/SettingsPanel.tsx | 240 ++++++++++++++++++++++++++++ src/components/TaskList.tsx | 283 +++++++++++++++++++++++++++++++++ src/components/TimerView.tsx | 236 +++++++++++++++++++++++++++ src/hooks/useTimerEvents.ts | 78 +++++++++ src/store/settingsStore.ts | 33 ++++ src/store/taskStore.ts | 80 ++++++++++ src/store/timerStore.ts | 42 +++++ 9 files changed, 1217 insertions(+), 5 deletions(-) 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 create mode 100644 src/hooks/useTimerEvents.ts create mode 100644 src/store/settingsStore.ts create mode 100644 src/store/taskStore.ts create mode 100644 src/store/timerStore.ts (limited to 'src') 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 ( + + + + + ); +} + function App() { + const [settingsOpen, setSettingsOpen] = useState(false); + const [notifVisible, setNotifVisible] = useState(false); + const [notifTaskId, setNotifTaskId] = useState(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 ( -
-
-

Solstice

-

Pomodoro timer with ambient soundscapes.

+
+ {/* Sidebar */} + + + {/* Main area */} +
+ {/* Top bar */} +
+ +
+ + {/* Timer */} +
-
+ + {/* Overlays */} + setSettingsOpen(false)} /> + setNotifVisible(false)} + /> + ); } 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 ( + + ); +} 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('timer-tick', (event) => { + setTimerTick(event.payload); + setRunning(true); + }).then((fn) => { unlistenTick = fn; }); + + listen('timer-completed', (event) => { + setRunning(false); + onCompleted(event.payload.task_id); + // Refresh tasks since remaining_sessions may have changed + fetchTasks(); + }).then((fn) => { unlistenCompleted = fn; }); + + listen('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; + updateSettings: (s: Settings) => Promise; +} + +export const useSettingsStore = create((set) => ({ + settings: null, + + fetchSettings: async () => { + try { + const settings = await invoke('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; + addTask: (name: string, totalSessions: number) => Promise; + updateTask: (id: string, remainingSessions?: number, completed?: boolean) => Promise; + deleteTask: (id: string) => Promise; + setCurrentTask: (id: string | null) => Promise; + refreshTask: (task: Task) => void; +} + +export const useTaskStore = create((set) => ({ + tasks: [], + loading: false, + + fetchTasks: async () => { + set({ loading: true }); + try { + const tasks = await invoke('get_tasks'); + set({ tasks, loading: false }); + } catch (e) { + console.error('fetchTasks error:', e); + set({ loading: false }); + } + }, + + addTask: async (name, totalSessions) => { + const task = await invoke('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((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 }), +})); -- cgit v1.3-2-g0d8e