diff options
| author | Solstice <solstice@local> | 2026-06-09 00:13:03 -0700 |
|---|---|---|
| committer | Solstice <solstice@local> | 2026-06-09 00:13:03 -0700 |
| commit | 72626524c4a1c7d6642bc170520913273acb1a5c (patch) | |
| tree | deeaa45248c6c80f373c2d956cc51e0a4dc7f2cf /src-tauri/src | |
| parent | 8cf1686e9e9ed20a1c256ccf9e510f4293aa1cf3 (diff) | |
feat: backend timer logic and data persistence
Diffstat (limited to 'src-tauri/src')
| -rw-r--r-- | src-tauri/src/lib.rs | 292 | ||||
| -rw-r--r-- | src-tauri/src/state.rs | 45 | ||||
| -rw-r--r-- | src-tauri/src/storage.rs | 75 | ||||
| -rw-r--r-- | src-tauri/src/timer.rs | 135 |
4 files changed, 543 insertions, 4 deletions
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..3ca8d99 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,298 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +mod state; +mod storage; +mod timer; + +use std::sync::{Arc, Mutex}; +use chrono::Utc; +use tauri::{AppHandle, Manager, State}; +use uuid::Uuid; + +use state::{AppDataWrapper, TimerPhase, TimerState, TimerStateWrapper}; +use storage::{Settings, Task}; + +// ── Helper ────────────────────────────────────────────────────────────────── + +fn app_data_dir(app: &AppHandle) -> std::path::PathBuf { + app.path().app_data_dir().expect("Failed to resolve app data dir") +} + +// ── Timer commands ────────────────────────────────────────────────────────── + +#[tauri::command] +fn get_timer_status(timer: State<'_, TimerStateWrapper>) -> serde_json::Value { + let ts = timer.0.lock().unwrap(); + serde_json::json!({ + "phase": ts.phase, + "remaining_secs": ts.remaining_secs, + "total_secs": ts.total_secs, + "running": ts.running, + "session_count": ts.session_count, + "current_task_id": ts.current_task_id, + }) +} + +#[tauri::command] +fn start_timer(timer: State<'_, TimerStateWrapper>) { + let mut ts = timer.0.lock().unwrap(); + ts.running = true; +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn pause_timer(timer: State<'_, TimerStateWrapper>) { + let mut ts = timer.0.lock().unwrap(); + ts.running = false; } +#[tauri::command] +fn reset_timer(timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>) { + let mut ts = timer.0.lock().unwrap(); + let total = { + let app_data = data.data.lock().unwrap(); + match ts.phase { + TimerPhase::Work => app_data.settings.work_duration_secs, + TimerPhase::ShortBreak => app_data.settings.short_break_secs, + TimerPhase::LongBreak => app_data.settings.long_break_secs, + } + }; + ts.remaining_secs = total; + ts.total_secs = total; + ts.running = false; +} + +#[tauri::command] +fn skip_phase( + timer: State<'_, TimerStateWrapper>, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) { + use tauri::Emitter; + let mut ts = timer.0.lock().unwrap(); + let app_data = data.data.lock().unwrap(); + let s = &app_data.settings; + + match ts.phase { + TimerPhase::Work => { + ts.session_count += 1; + let (next_phase, next_secs) = + if ts.session_count % s.sessions_before_long_break == 0 { + (TimerPhase::LongBreak, s.long_break_secs) + } else { + (TimerPhase::ShortBreak, s.short_break_secs) + }; + ts.phase = next_phase; + ts.total_secs = next_secs; + ts.remaining_secs = next_secs; + } + TimerPhase::ShortBreak | TimerPhase::LongBreak => { + ts.phase = TimerPhase::Work; + ts.total_secs = s.work_duration_secs; + ts.remaining_secs = s.work_duration_secs; + } + } + + ts.running = false; + + let _ = app.emit( + "timer-phase-changed", + timer::PhaseChangedPayload { + phase: ts.phase, + session_count: ts.session_count, + }, + ); +} + +#[tauri::command] +fn set_current_task( + task_id: Option<String>, + timer: State<'_, TimerStateWrapper>, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) -> Result<(), String> { + { + let mut app_data = data.data.lock().unwrap(); + // Validate task exists if Some + 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; + } + Ok(()) +} + +// ── Settings commands ─────────────────────────────────────────────────────── + +#[tauri::command] +fn get_settings(data: State<'_, AppDataWrapper>) -> Settings { + data.data.lock().unwrap().settings.clone() +} + +#[tauri::command] +fn update_settings( + settings: Settings, + data: State<'_, AppDataWrapper>, + timer: State<'_, TimerStateWrapper>, + app: AppHandle, +) -> Result<(), String> { + { + 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 + { + let mut ts = timer.0.lock().unwrap(); + ts.phase = TimerPhase::Work; + ts.total_secs = settings.work_duration_secs; + ts.remaining_secs = settings.work_duration_secs; + ts.running = false; + ts.session_count = 0; + } + Ok(()) +} + +// ── Task commands ─────────────────────────────────────────────────────────── + +#[tauri::command] +fn get_tasks(data: State<'_, AppDataWrapper>) -> Vec<Task> { + data.data.lock().unwrap().tasks.clone() +} + +#[tauri::command] +fn add_task( + name: String, + total_sessions: u32, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) -> Result<Task, String> { + let task = Task { + id: Uuid::new_v4().to_string(), + name, + total_sessions, + remaining_sessions: total_sessions, + completed: false, + created_at: Utc::now().to_rfc3339(), + }; + { + let mut app_data = data.data.lock().unwrap(); + app_data.tasks.push(task.clone()); + storage::save(&app_data_dir(&app), &app_data)?; + } + Ok(task) +} + +#[tauri::command] +fn update_task( + id: String, + remaining_sessions: Option<u32>, + completed: Option<bool>, + data: State<'_, AppDataWrapper>, + app: AppHandle, +) -> Result<(), String> { + let mut app_data = data.data.lock().unwrap(); + let task = app_data + .tasks + .iter_mut() + .find(|t| t.id == id) + .ok_or_else(|| format!("Task {} not found", id))?; + if let Some(r) = remaining_sessions { + task.remaining_sessions = r; + } + if let Some(c) = completed { + task.completed = c; + } + storage::save(&app_data_dir(&app), &app_data)?; + Ok(()) +} + +#[tauri::command] +fn delete_task( + id: String, + data: State<'_, AppDataWrapper>, + timer: State<'_, TimerStateWrapper>, + app: AppHandle, +) -> Result<(), String> { + { + let mut app_data = data.data.lock().unwrap(); + let before = app_data.tasks.len(); + app_data.tasks.retain(|t| t.id != id); + if app_data.tasks.len() == before { + return Err(format!("Task {} not found", id)); + } + // Clear current_task_id if it was this task + if app_data.current_task_id.as_deref() == Some(&id) { + app_data.current_task_id = None; + } + storage::save(&app_data_dir(&app), &app_data)?; + } + { + let mut ts = timer.0.lock().unwrap(); + if ts.current_task_id.as_deref() == Some(&id) { + ts.current_task_id = None; + } + } + Ok(()) +} + +// ── App entry point ───────────────────────────────────────────────────────── + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .setup(|app| { + let data_dir = app.handle().path().app_data_dir() + .expect("Failed to resolve app data dir"); + + // 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 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 + })); + + // Register managed state + app.manage(TimerStateWrapper(Arc::clone(&timer_arc))); + app.manage(AppDataWrapper { + data: Arc::clone(&data_arc), + data_dir: data_dir.clone(), + }); + + // Spawn background timer thread + timer::spawn_timer_thread( + app.handle().clone(), + Arc::clone(&timer_arc), + Arc::clone(&data_arc), + data_dir, + ); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + get_timer_status, + start_timer, + pause_timer, + reset_timer, + skip_phase, + set_current_task, + get_settings, + update_settings, + get_tasks, + add_task, + update_task, + delete_task, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..9cd3698 --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,45 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use serde::{Deserialize, Serialize}; +use crate::storage::AppData; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TimerPhase { + Work, + ShortBreak, + LongBreak, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TimerState { + pub phase: TimerPhase, + pub remaining_secs: u64, + pub total_secs: u64, + pub running: bool, + pub session_count: u32, + pub current_task_id: Option<String>, +} + +/// Wrapper held in Tauri managed state — contains the Arc so commands can clone it. +pub struct TimerStateWrapper(pub Arc<Mutex<TimerState>>); + +/// Wrapper for AppData — contains the Arc and the data directory path. +pub struct AppDataWrapper { + pub data: Arc<Mutex<AppData>>, + #[allow(dead_code)] + pub data_dir: PathBuf, +} + +impl TimerState { + pub fn new(work_duration_secs: u64) -> Self { + Self { + phase: TimerPhase::Work, + remaining_secs: work_duration_secs, + total_secs: work_duration_secs, + running: false, + session_count: 0, + current_task_id: None, + } + } +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..28b1f86 --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub work_duration_secs: u64, + pub short_break_secs: u64, + pub long_break_secs: u64, + pub sessions_before_long_break: u32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + work_duration_secs: 25 * 60, + short_break_secs: 5 * 60, + long_break_secs: 15 * 60, + sessions_before_long_break: 4, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Task { + pub id: String, + pub name: String, + pub total_sessions: u32, + pub remaining_sessions: u32, + pub completed: bool, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppData { + pub settings: Settings, + pub tasks: Vec<Task>, + pub current_task_id: Option<String>, +} + +impl Default for AppData { + fn default() -> Self { + Self { + settings: Settings::default(), + tasks: Vec::new(), + current_task_id: None, + } + } +} + +pub fn data_path(app_data_dir: &PathBuf) -> PathBuf { + app_data_dir.join("data.json") +} + +pub fn load(app_data_dir: &PathBuf) -> AppData { + let path = data_path(app_data_dir); + if !path.exists() { + return AppData::default(); + } + match fs::read_to_string(&path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => AppData::default(), + } +} + +pub fn save(app_data_dir: &PathBuf, data: &AppData) -> Result<(), String> { + fs::create_dir_all(app_data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + let path = data_path(app_data_dir); + let contents = serde_json::to_string_pretty(data) + .map_err(|e| format!("Failed to serialize data: {}", e))?; + fs::write(&path, contents) + .map_err(|e| format!("Failed to write data file: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs new file mode 100644 index 0000000..7e26ad8 --- /dev/null +++ b/src-tauri/src/timer.rs @@ -0,0 +1,135 @@ +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +use crate::state::{TimerPhase, TimerState}; +use crate::storage::{self, AppData}; + +// ── Event payloads ────────────────────────────────────────────────────────── + +#[derive(Clone, Serialize)] +pub struct TickPayload { + pub phase: TimerPhase, + pub remaining_secs: u64, + pub total_secs: u64, + pub session_count: u32, + pub current_task_id: Option<String>, +} + +#[derive(Clone, Serialize)] +pub struct PhaseChangedPayload { + pub phase: TimerPhase, + pub session_count: u32, +} + +#[derive(Clone, Serialize)] +pub struct CompletedPayload { + pub task_id: Option<String>, +} + +// ── Timer thread ──────────────────────────────────────────────────────────── + +/// Spawns the background timer thread. The thread runs for the lifetime of +/// the application; it sleeps while `running == false`. +pub fn spawn_timer_thread( + app_handle: AppHandle, + timer_arc: Arc<Mutex<TimerState>>, + data_arc: Arc<Mutex<AppData>>, + data_dir: std::path::PathBuf, +) { + thread::spawn(move || { + loop { + thread::sleep(Duration::from_secs(1)); + + let mut ts = timer_arc.lock().unwrap(); + + if !ts.running { + continue; + } + + // Decrement + if ts.remaining_secs > 0 { + ts.remaining_secs -= 1; + } + + // Emit tick + let tick = TickPayload { + phase: ts.phase, + remaining_secs: ts.remaining_secs, + total_secs: ts.total_secs, + session_count: ts.session_count, + current_task_id: ts.current_task_id.clone(), + }; + let _ = app_handle.emit("timer-tick", tick); + + // Check if phase has reached zero + if ts.remaining_secs == 0 { + ts.running = false; + + 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(); + { + let mut data = data_arc.lock().unwrap(); + if let Some(ref tid) = task_id { + if let Some(task) = data.tasks.iter_mut().find(|t| &t.id == tid) { + if task.remaining_sessions > 0 { + task.remaining_sessions -= 1; + } + if task.remaining_sessions == 0 { + task.completed = true; + } + } + } + let _ = storage::save(&data_dir, &data); + } + + ts.session_count += 1; + + // Determine next phase + let (next_phase, next_duration) = { + let data = data_arc.lock().unwrap(); + let s = &data.settings; + if ts.session_count % s.sessions_before_long_break == 0 { + (TimerPhase::LongBreak, s.long_break_secs) + } else { + (TimerPhase::ShortBreak, s.short_break_secs) + } + }; + + ts.phase = next_phase; + ts.total_secs = next_duration; + ts.remaining_secs = next_duration; + } + + TimerPhase::ShortBreak | TimerPhase::LongBreak => { + // Transition back to work + let work_secs = { + let data = data_arc.lock().unwrap(); + data.settings.work_duration_secs + }; + ts.phase = TimerPhase::Work; + ts.total_secs = work_secs; + ts.remaining_secs = work_secs; + } + } + + // Emit phase-changed after state is updated + let phase_changed = PhaseChangedPayload { + phase: ts.phase, + session_count: ts.session_count, + }; + let _ = app_handle.emit("timer-phase-changed", phase_changed); + } + } + }); +} |
