diff options
| -rw-r--r-- | src-tauri/src/lib.rs | 66 | ||||
| -rw-r--r-- | src-tauri/src/state.rs | 26 | ||||
| -rw-r--r-- | src-tauri/src/storage.rs | 108 | ||||
| -rw-r--r-- | src-tauri/src/timer.rs | 35 | ||||
| -rw-r--r-- | src/components/TimerView.tsx | 23 | ||||
| -rw-r--r-- | src/hooks/useTimerEvents.ts | 44 | ||||
| -rw-r--r-- | src/store/audioStore.ts | 14 | ||||
| -rw-r--r-- | src/store/taskStore.ts | 7 | ||||
| -rw-r--r-- | src/store/timerStore.ts | 25 |
9 files changed, 279 insertions, 69 deletions
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c298fdf..aff41cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,17 @@ fn app_data_dir(app: &AppHandle) -> std::path::PathBuf { app.path().app_data_dir().expect("Failed to resolve app data dir") } +fn persist_timer_snapshot( + timer: &TimerState, + data: &State<'_, AppDataWrapper>, + app: &AppHandle, +) -> Result<(), String> { + let mut app_data = data.data.lock().unwrap(); + app_data.current_task_id = timer.current_task_id.clone(); + app_data.timer_snapshot = Some(timer.snapshot()); + storage::save(&app_data_dir(app), &app_data) +} + fn sync_audio_ducking(audio: &AudioState, phase: TimerPhase) { if let Some(engine) = audio.0.lock().unwrap().as_mut() { if audio::should_duck_for_phase(phase) { @@ -49,19 +60,33 @@ fn get_timer_status(timer: State<'_, TimerStateWrapper>) -> serde_json::Value { } #[tauri::command] -fn start_timer(timer: State<'_, TimerStateWrapper>) { +fn start_timer( + timer: State<'_, TimerStateWrapper>, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); ts.running = true; + persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] -fn pause_timer(timer: State<'_, TimerStateWrapper>) { +fn pause_timer( + timer: State<'_, TimerStateWrapper>, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); ts.running = false; + persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] -fn reset_timer(timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>) { +fn reset_timer( + timer: State<'_, TimerStateWrapper>, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); let total = { let app_data = data.data.lock().unwrap(); @@ -74,6 +99,7 @@ fn reset_timer(timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapp ts.remaining_secs = total; ts.total_secs = total; ts.running = false; + persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] @@ -82,7 +108,7 @@ fn skip_phase( data: State<'_, AppDataWrapper>, audio: State<'_, AudioState>, app: AppHandle, -) { +) -> Result<(), String> { use tauri::Emitter; let mut ts = timer.0.lock().unwrap(); let app_data = data.data.lock().unwrap(); @@ -111,6 +137,7 @@ fn skip_phase( ts.running = false; let phase = ts.phase; let session_count = ts.session_count; + persist_timer_snapshot(&ts, &data, &app)?; drop(ts); sync_audio_ducking(&audio, phase); @@ -122,6 +149,8 @@ fn skip_phase( session_count, }, ); + + Ok(()) } #[tauri::command] @@ -132,19 +161,17 @@ fn set_current_task( app: AppHandle, ) -> Result<(), String> { { - let mut app_data = data.data.lock().unwrap(); - // Validate task exists if Some + let app_data = data.data.lock().unwrap(); if let Some(ref tid) = task_id { if !app_data.tasks.iter().any(|t| &t.id == tid) { return Err(format!("Task {} not found", tid)); } } - app_data.current_task_id = task_id.clone(); - storage::save(&app_data_dir(&app), &app_data)?; } { let mut ts = timer.0.lock().unwrap(); ts.current_task_id = task_id; + persist_timer_snapshot(&ts, &data, &app)?; } Ok(()) } @@ -170,7 +197,6 @@ fn update_settings( { let mut app_data = data.data.lock().unwrap(); app_data.settings = settings.clone(); - storage::save(&app_data_dir(&app), &app_data)?; } // Restart timer at new work duration { @@ -180,6 +206,7 @@ fn update_settings( ts.remaining_secs = settings.work_duration_secs; ts.running = false; ts.session_count = 0; + persist_timer_snapshot(&ts, &data, &app)?; } sync_audio_ducking(&audio, TimerPhase::Work); Ok(()) @@ -199,6 +226,14 @@ fn add_task( data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<Task, String> { + let name = name.trim().to_string(); + if name.is_empty() { + return Err("Task name cannot be empty".to_string()); + } + if total_sessions == 0 { + return Err("total_sessions must be at least 1".to_string()); + } + let task = Task { id: Uuid::new_v4().to_string(), name, @@ -268,6 +303,7 @@ fn delete_task( let mut ts = timer.0.lock().unwrap(); if ts.current_task_id.as_deref() == Some(&id) { ts.current_task_id = None; + persist_timer_snapshot(&ts, &data, &app)?; } } Ok(()) @@ -363,14 +399,14 @@ pub fn run() { // Load persisted data let app_data = storage::load(&data_dir); let work_secs = app_data.settings.work_duration_secs; - let current_task_id = app_data.current_task_id.clone(); + let timer_snapshot = app_data.timer_snapshot.clone(); let data_arc = Arc::new(Mutex::new(app_data)); - let timer_arc = Arc::new(Mutex::new({ - let mut ts = TimerState::new(work_secs); - ts.current_task_id = current_task_id; - ts - })); + let timer_arc = Arc::new(Mutex::new( + timer_snapshot + .map(TimerState::from_snapshot) + .unwrap_or_else(|| TimerState::new(work_secs)) + )); // Register managed state app.manage(TimerStateWrapper(Arc::clone(&timer_arc))); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8f3d52b..c6b4d03 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,6 @@ use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; -use crate::storage::AppData; +use crate::storage::{AppData, TimerSnapshot}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -10,7 +10,7 @@ pub enum TimerPhase { LongBreak, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimerState { pub phase: TimerPhase, pub remaining_secs: u64, @@ -39,4 +39,26 @@ impl TimerState { current_task_id: None, } } + + pub fn from_snapshot(snapshot: TimerSnapshot) -> Self { + Self { + phase: snapshot.phase, + remaining_secs: snapshot.remaining_secs, + total_secs: snapshot.total_secs, + running: snapshot.running, + session_count: snapshot.session_count, + current_task_id: snapshot.current_task_id, + } + } + + pub fn snapshot(&self) -> TimerSnapshot { + TimerSnapshot { + phase: self.phase, + remaining_secs: self.remaining_secs, + total_secs: self.total_secs, + running: self.running, + session_count: self.session_count, + current_task_id: self.current_task_id.clone(), + } + } } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 8e2f148..2e726a3 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -2,6 +2,8 @@ use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::state::TimerPhase; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub work_duration_secs: u64, @@ -32,10 +34,22 @@ pub struct Task { } #[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimerSnapshot { + pub phase: TimerPhase, + pub remaining_secs: u64, + pub total_secs: u64, + pub running: bool, + pub session_count: u32, + pub current_task_id: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppData { pub settings: Settings, pub tasks: Vec<Task>, pub current_task_id: Option<String>, + #[serde(default)] + pub timer_snapshot: Option<TimerSnapshot>, } impl Default for AppData { @@ -44,6 +58,7 @@ impl Default for AppData { settings: Settings::default(), tasks: Vec::new(), current_task_id: None, + timer_snapshot: None, } } } @@ -77,7 +92,100 @@ pub fn save(app_data_dir: &PathBuf, data: &AppData) -> Result<(), String> { let tmp_path = app_data_dir.join("data.json.tmp"); fs::write(&tmp_path, contents) .map_err(|e| format!("Failed to write temp data file: {}", e))?; + + if cfg!(windows) { + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove existing data file: {}", e))?; + } + } + fs::rename(&tmp_path, &path) .map_err(|e| format!("Failed to finalize data file: {}", e))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{load, save, AppData, Settings, Task, TimerSnapshot}; + use crate::state::TimerPhase; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(label: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("solstice-storage-{label}-{unique}")) + } + + #[test] + fn load_preserves_persisted_timer_snapshot() { + let dir = temp_dir("load-timer-snapshot"); + fs::create_dir_all(&dir).unwrap(); + + let json = serde_json::json!({ + "settings": { + "work_duration_secs": 1500, + "short_break_secs": 300, + "long_break_secs": 900, + "sessions_before_long_break": 4 + }, + "tasks": [], + "current_task_id": "task-1", + "timer_snapshot": { + "phase": "short_break", + "remaining_secs": 120, + "total_secs": 300, + "running": false, + "session_count": 2, + "current_task_id": "task-1" + } + }) + .to_string(); + fs::write(dir.join("data.json"), json).unwrap(); + + let data = load(&dir); + + let snapshot = data.timer_snapshot.expect("expected timer snapshot"); + assert_eq!(snapshot.phase, TimerPhase::ShortBreak); + assert_eq!(snapshot.remaining_secs, 120); + assert_eq!(snapshot.total_secs, 300); + assert_eq!(snapshot.session_count, 2); + assert_eq!(snapshot.current_task_id.as_deref(), Some("task-1")); + } + + #[test] + fn save_persists_timer_snapshot() { + let dir = temp_dir("save-timer-snapshot"); + let data = AppData { + settings: Settings::default(), + tasks: vec![Task { + id: "task-1".to_string(), + name: "Deep work".to_string(), + total_sessions: 4, + remaining_sessions: 3, + completed: false, + created_at: "2026-06-09T00:00:00Z".to_string(), + }], + current_task_id: Some("task-1".to_string()), + timer_snapshot: Some(TimerSnapshot { + phase: TimerPhase::Work, + remaining_secs: 600, + total_secs: 1500, + running: true, + session_count: 1, + current_task_id: Some("task-1".to_string()), + }), + }; + + save(&dir, &data).unwrap(); + let reloaded = load(&dir); + + let snapshot = reloaded.timer_snapshot.expect("expected timer snapshot"); + assert!(snapshot.running); + assert_eq!(snapshot.remaining_secs, 600); + assert_eq!(snapshot.total_secs, 1500); + } +} diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs index 322795e..02ae285 100644 --- a/src-tauri/src/timer.rs +++ b/src-tauri/src/timer.rs @@ -8,6 +8,12 @@ use crate::audio::{self, AudioEngine}; use crate::state::{TimerPhase, TimerState}; use crate::storage::{self, AppData}; +fn persist_timer_state(data: &mut AppData, timer: &TimerState, data_dir: &std::path::PathBuf) { + data.current_task_id = timer.current_task_id.clone(); + data.timer_snapshot = Some(timer.snapshot()); + let _ = storage::save(data_dir, data); +} + // LOCK ORDERING: When acquiring multiple locks, always take timer_state // before app_data to prevent deadlock. Never acquire app_data first // while timer_state is not held. @@ -68,20 +74,22 @@ pub fn spawn_timer_thread( session_count: ts.session_count, current_task_id: ts.current_task_id.clone(), }; + + { + let mut data = data_arc.lock().unwrap(); + persist_timer_state(&mut data, &ts, &data_dir); + } + let _ = app_handle.emit("timer-tick", tick); // Check if phase has reached zero if ts.remaining_secs == 0 { ts.running = false; + let mut emit_completed = false; + let mut completed_task_id = None; match ts.phase { TimerPhase::Work => { - // Emit completion before transitioning - let completed = CompletedPayload { - task_id: ts.current_task_id.clone(), - }; - let _ = app_handle.emit("timer-completed", completed); - // Decrement task remaining sessions let task_id = ts.current_task_id.clone(); { @@ -96,8 +104,9 @@ pub fn spawn_timer_thread( } } } - let _ = storage::save(&data_dir, &data); } + emit_completed = true; + completed_task_id = task_id; ts.session_count += 1; @@ -129,6 +138,11 @@ pub fn spawn_timer_thread( } } + { + let mut data = data_arc.lock().unwrap(); + persist_timer_state(&mut data, &ts, &data_dir); + } + // Emit phase-changed after state is updated if let Some(engine) = audio_arc.lock().unwrap().as_mut() { if audio::should_duck_for_phase(ts.phase) { @@ -138,6 +152,13 @@ pub fn spawn_timer_thread( } } + if emit_completed { + let completed = CompletedPayload { + task_id: completed_task_id, + }; + let _ = app_handle.emit("timer-completed", completed); + } + let phase_changed = PhaseChangedPayload { phase: ts.phase, session_count: ts.session_count, diff --git a/src/components/TimerView.tsx b/src/components/TimerView.tsx index 896c03a..b43973c 100644 --- a/src/components/TimerView.tsx +++ b/src/components/TimerView.tsx @@ -54,6 +54,7 @@ export function TimerView() { const { phase, remainingSecs, totalSecs, running, sessionCount, currentTaskId } = useTimerStore(); const tasks = useTaskStore((s) => s.tasks); + const syncFromBackend = useTimerStore((s) => s.syncFromBackend); const currentTask = tasks.find((t) => t.id === currentTaskId) ?? null; @@ -61,10 +62,22 @@ export function TimerView() { 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'); + const handleStart = async () => { + await invoke('start_timer'); + await syncFromBackend(); + }; + const handlePause = async () => { + await invoke('pause_timer'); + await syncFromBackend(); + }; + const handleSkip = async () => { + await invoke('skip_phase'); + await syncFromBackend(); + }; + const handleReset = async () => { + await invoke('reset_timer'); + await syncFromBackend(); + }; return ( <div @@ -200,7 +213,7 @@ function GhostButton({ onClick, children, }: { - onClick: () => void; + onClick: () => Promise<void>; children: React.ReactNode; }) { return ( diff --git a/src/hooks/useTimerEvents.ts b/src/hooks/useTimerEvents.ts index aebc780..a0d5154 100644 --- a/src/hooks/useTimerEvents.ts +++ b/src/hooks/useTimerEvents.ts @@ -1,6 +1,5 @@ import { useEffect, useRef } 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'; @@ -9,15 +8,6 @@ interface PhaseChangedPayload { session_count: number; } -interface TimerStatus { - phase: 'work' | 'short_break' | 'long_break'; - remaining_secs: number; - total_secs: number; - running: boolean; - session_count: number; - current_task_id: string | null; -} - interface CompletedPayload { task_id: string | null; } @@ -25,7 +15,7 @@ interface CompletedPayload { export function useTimerEvents( onCompleted: (taskId: string | null) => void, ) { - const setTimerTick = useTimerStore((s) => s.setTimerTick); + const syncFromBackend = useTimerStore((s) => s.syncFromBackend); const setRunning = useTimerStore((s) => s.setRunning); const fetchTasks = useTaskStore((s) => s.fetchTasks); @@ -41,16 +31,8 @@ export function useTimerEvents( async function setup() { // Bootstrap initial state from backend try { - const status = await invoke<TimerStatus>('get_timer_status'); + await syncFromBackend(); if (cancelled) return; - 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('Failed to get timer status:', e); } @@ -61,24 +43,24 @@ export function useTimerEvents( try { const [unlistenTick, unlistenCompleted, unlistenPhaseChanged] = await Promise.all([ listen<TimerTickPayload>('timer-tick', (event) => { - setTimerTick(event.payload); - setRunning(true); + useTimerStore.setState({ + phase: event.payload.phase, + remainingSecs: event.payload.remaining_secs, + totalSecs: event.payload.total_secs, + running: true, + sessionCount: event.payload.session_count, + currentTaskId: event.payload.current_task_id, + }); }), listen<CompletedPayload>('timer-completed', async (event) => { setRunning(false); onCompletedRef.current(event.payload.task_id ?? null); await fetchTasks(); + await syncFromBackend(); }), listen<PhaseChangedPayload>('timer-phase-changed', async (_event) => { try { - const status = await invoke<TimerStatus>('get_timer_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, - }); + await syncFromBackend(); } catch (e) { console.error('Failed to re-sync timer status:', e); } @@ -104,5 +86,5 @@ export function useTimerEvents( cancelled = true; unlisteners.forEach((fn) => fn()); }; - }, [setTimerTick, setRunning, fetchTasks]); // onCompleted excluded — updated via ref + }, [syncFromBackend, setRunning, fetchTasks]); // onCompleted excluded — updated via ref } diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts index 39cbf8b..36d593d 100644 --- a/src/store/audioStore.ts +++ b/src/store/audioStore.ts @@ -53,15 +53,15 @@ export const useAudioStore = create<AudioStore>((set, get) => ({ try { if (sound === 'none') { await invoke('stop_ambient'); - set({ playing: false, sound: 'none' }); + await get().fetchStatus(); return; } await invoke('play_ambient', { sound }); - set({ available: true, playing: true, sound }); + await get().fetchStatus(); } catch (error) { console.error('play_ambient error:', error); - set({ available: false, playing: false, sound: 'none' }); + await get().fetchStatus(); } }, @@ -71,14 +71,10 @@ export const useAudioStore = create<AudioStore>((set, get) => ({ try { await invoke('set_ambient_volume', { volume: nextVolume }); - set({ available: true }); + await get().fetchStatus(); } catch (error) { console.error('set_ambient_volume error:', error); - set({ - available: false, - playing: false, - sound: get().sound, - }); + await get().fetchStatus(); } }, })); diff --git a/src/store/taskStore.ts b/src/store/taskStore.ts index 94386ac..2a3a8be 100644 --- a/src/store/taskStore.ts +++ b/src/store/taskStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; +import { useTimerStore } from './timerStore'; export interface Task { id: string; @@ -79,5 +80,11 @@ export const useTaskStore = create<TaskStore>((set) => ({ setCurrentTask: async (id) => { await invoke('set_current_task', { taskId: id }); + useTimerStore.getState().setCurrentTaskId(id); + try { + await useTimerStore.getState().syncFromBackend(); + } catch (e) { + console.error('syncFromBackend after set_current_task error:', e); + } }, })); diff --git a/src/store/timerStore.ts b/src/store/timerStore.ts index 6a81e20..79170a4 100644 --- a/src/store/timerStore.ts +++ b/src/store/timerStore.ts @@ -1,3 +1,4 @@ +import { invoke } from '@tauri-apps/api/core'; import { create } from 'zustand'; export type TimerPhase = 'work' | 'short_break' | 'long_break'; @@ -10,6 +11,15 @@ export interface TimerTickPayload { current_task_id: string | null; } +export interface TimerStatus { + phase: TimerPhase; + remaining_secs: number; + total_secs: number; + running: boolean; + session_count: number; + current_task_id: string | null; +} + interface TimerState { phase: TimerPhase; remainingSecs: number; @@ -17,8 +27,10 @@ interface TimerState { running: boolean; sessionCount: number; currentTaskId: string | null; + syncFromBackend: () => Promise<void>; setTimerTick: (payload: TimerTickPayload) => void; setRunning: (running: boolean) => void; + setCurrentTaskId: (currentTaskId: string | null) => void; } export const useTimerStore = create<TimerState>((set) => ({ @@ -29,6 +41,18 @@ export const useTimerStore = create<TimerState>((set) => ({ sessionCount: 0, currentTaskId: null, + syncFromBackend: async () => { + const status = await invoke<TimerStatus>('get_timer_status'); + set({ + phase: status.phase, + remainingSecs: status.remaining_secs, + totalSecs: status.total_secs, + running: status.running, + sessionCount: status.session_count, + currentTaskId: status.current_task_id, + }); + }, + setTimerTick: (payload) => set({ phase: payload.phase, @@ -39,4 +63,5 @@ export const useTimerStore = create<TimerState>((set) => ({ }), setRunning: (running) => set({ running }), + setCurrentTaskId: (currentTaskId) => set({ currentTaskId }), })); |
