summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json14
-rw-r--r--package.json1
-rw-r--r--src-tauri/Cargo.lock128
-rw-r--r--src-tauri/Cargo.toml1
-rw-r--r--src-tauri/capabilities/default.json3
-rw-r--r--src-tauri/src/lib.rs1
-rw-r--r--src/App.tsx113
-rw-r--r--src/components/NotificationOverlay.tsx117
-rw-r--r--src/components/SettingsPanel.tsx240
-rw-r--r--src/components/TaskList.tsx283
-rw-r--r--src/components/TimerView.tsx236
-rw-r--r--src/hooks/useTimerEvents.ts78
-rw-r--r--src/store/settingsStore.ts33
-rw-r--r--src/store/taskStore.ts80
-rw-r--r--src/store/timerStore.ts42
15 files changed, 1361 insertions, 9 deletions
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
@@ -1956,6 +1956,18 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2060,6 +2072,20 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
]
@@ -2536,6 +2563,15 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2615,6 +2651,15 @@ dependencies = [
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
@@ -2644,6 +2689,35 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3366,6 +3440,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
+ "tauri-plugin-notification",
"tauri-plugin-opener",
"uuid",
]
@@ -3449,6 +3524,25 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3571,6 +3665,18 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4985,6 +5091,26 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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 (
+ <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 }),
+}));