summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSolstice <solstice@local>2026-06-09 00:13:03 -0700
committerSolstice <solstice@local>2026-06-09 00:13:03 -0700
commit72626524c4a1c7d6642bc170520913273acb1a5c (patch)
treedeeaa45248c6c80f373c2d956cc51e0a4dc7f2cf
parent8cf1686e9e9ed20a1c256ccf9e510f4293aa1cf3 (diff)
feat: backend timer logic and data persistence
-rw-r--r--src-tauri/Cargo.lock4
-rw-r--r--src-tauri/Cargo.toml2
-rw-r--r--src-tauri/src/lib.rs292
-rw-r--r--src-tauri/src/state.rs45
-rw-r--r--src-tauri/src/storage.rs75
-rw-r--r--src-tauri/src/timer.rs135
6 files changed, 549 insertions, 4 deletions
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 97eaa99..c5cfd50 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -460,8 +460,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
+ "js-sys",
"num-traits",
"serde",
+ "wasm-bindgen",
"windows-link 0.2.1",
]
@@ -3359,11 +3361,13 @@ dependencies = [
name = "tauri-app"
version = "0.1.0"
dependencies = [
+ "chrono",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
+ "uuid",
]
[[package]]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 6875383..149d8bd 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -22,4 +22,6 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+uuid = { version = "1", features = ["v4"] }
+chrono = { version = "0.4", features = ["serde"] }
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);
+ }
+ }
+ });
+}