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 --- package-lock.json | 14 +- package.json | 1 + src-tauri/Cargo.lock | 128 ++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 1 + 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 +++++ 15 files changed, 1361 insertions(+), 9 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 diff --git a/package-lock.json b/package-lock.json index 8bd2ad0..9e86eb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "tauri-app", + "name": "solstice", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tauri-app", + "name": "solstice", "version": "0.1.0", "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -1650,6 +1651,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/package.json b/package.json index 5759bd4..6dfc96b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c5cfd50..9d35925 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1955,6 +1955,18 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "mac-notification-sys" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2059,6 +2071,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2219,6 +2245,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2475,7 +2502,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -2535,6 +2562,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2613,6 +2649,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -2643,6 +2688,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3366,6 +3440,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-notification", "tauri-plugin-opener", "uuid", ] @@ -3448,6 +3523,25 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -3570,6 +3664,18 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4984,6 +5090,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ed8d164..214a48f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..4625545 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "notification:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f596966..c9ebbd9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -258,6 +258,7 @@ fn delete_task( pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_notification::init()) .setup(|app| { let data_dir = app.handle().path().app_data_dir() .expect("Failed to resolve app data dir"); 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