diff --git a/apps/desktop/src-tauri/src/monitor.rs b/apps/desktop/src-tauri/src/monitor.rs index dd898ae..b3747c6 100644 --- a/apps/desktop/src-tauri/src/monitor.rs +++ b/apps/desktop/src-tauri/src/monitor.rs @@ -2,7 +2,8 @@ use crate::app_usage; use crate::idle; use crate::monitor_types::{MonitorSnapshot, ReminderStatus}; use crate::monitor_usage::{ - load_usage_by_app, save_usage_by_app, usage_entries, UsageBucket, UsageBuckets, + current_local_date_key, load_usage_state, reset_usage_for_new_date, save_usage_state, + usage_entries, UsageBucket, UsageBuckets, UsageState, }; use crate::settings::AppSettings; use std::collections::HashMap; @@ -23,6 +24,7 @@ pub struct MonitorStore(Arc>); struct MonitorRuntime { settings: AppSettings, idle_state: idle::IdleState, + usage_date: String, usage_by_app: UsageBuckets, status: ReminderStatus, snooze_until: Option, @@ -74,8 +76,9 @@ impl MonitorStore { } fn load_usage(&self, app: &AppHandle) { - let usage_by_app = match load_usage_by_app(app) { - Ok(usage_by_app) => usage_by_app, + let current_date = current_local_date_key(); + let usage_state = match load_usage_state(app, ¤t_date) { + Ok(usage_state) => usage_state, Err(error) => { if let Ok(mut runtime) = self.0.lock() { runtime.error_message = Some(error); @@ -85,17 +88,18 @@ impl MonitorStore { }; if let Ok(mut runtime) = self.0.lock() { - runtime.usage_by_app = usage_by_app; + runtime.usage_date = usage_state.tracked_date; + runtime.usage_by_app = usage_state.usage_by_app; } } fn save_usage(&self, app: &AppHandle) { - let usage_by_app = match self.0.lock() { - Ok(runtime) => runtime.usage_by_app.clone(), + let usage_state = match self.0.lock() { + Ok(runtime) => runtime.usage_state(), Err(_) => return, }; - if let Err(error) = save_usage_by_app(app, &usage_by_app) { + if let Err(error) = save_usage_state(app, &usage_state) { if let Ok(mut runtime) = self.0.lock() { runtime.error_message = Some(error); } @@ -111,6 +115,7 @@ impl MonitorRuntime { idle_seconds: 0, last_input_tick: 0, }, + usage_date: current_local_date_key(), usage_by_app: HashMap::new(), status: ReminderStatus::Monitoring, snooze_until: None, @@ -138,6 +143,12 @@ impl MonitorRuntime { } fn sample(&mut self) -> bool { + reset_usage_for_new_date( + &mut self.usage_date, + &mut self.usage_by_app, + ¤t_local_date_key(), + ); + if !self.settings.monitoring_enabled { self.is_reminder_visible = false; self.snooze_until = None; @@ -173,6 +184,13 @@ impl MonitorRuntime { self.update_reminder_state() } + fn usage_state(&self) -> UsageState { + UsageState { + tracked_date: self.usage_date.clone(), + usage_by_app: self.usage_by_app.clone(), + } + } + fn update_reminder_state(&mut self) -> bool { if self.idle_state.idle_seconds <= ACTIVE_RESET_SECONDS { self.is_temporarily_disabled = false; diff --git a/apps/desktop/src-tauri/src/monitor_usage.rs b/apps/desktop/src-tauri/src/monitor_usage.rs index 68fe635..4f86ad3 100644 --- a/apps/desktop/src-tauri/src/monitor_usage.rs +++ b/apps/desktop/src-tauri/src/monitor_usage.rs @@ -18,6 +18,13 @@ pub(crate) struct UsageBucket { pub(crate) window_title: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageState { + pub(crate) tracked_date: String, + pub(crate) usage_by_app: UsageBuckets, +} + pub(crate) fn usage_entries(usage_by_app: &UsageBuckets, total_seconds: u64) -> Vec { let mut entries = usage_by_app.values().cloned().collect::>(); entries.sort_by_key(|entry| Reverse(entry.seconds)); @@ -37,29 +44,99 @@ pub(crate) fn usage_entries(usage_by_app: &UsageBuckets, total_seconds: u64) -> .collect() } -pub(crate) fn load_usage_by_app(app: &AppHandle) -> Result { +pub(crate) fn current_local_date_key() -> String { + #[cfg(target_os = "windows")] + { + let local_time = unsafe { windows::Win32::System::SystemInformation::GetLocalTime() }; + format!( + "{:04}-{:02}-{:02}", + local_time.wYear, local_time.wMonth, local_time.wDay + ) + } + + #[cfg(not(target_os = "windows"))] + { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + date_key_from_unix_days((seconds / 86_400) as i64) + } +} + +pub(crate) fn is_stale_usage_date(tracked_date: &str, current_date: &str) -> bool { + tracked_date != current_date +} + +pub(crate) fn reset_usage_for_new_date( + tracked_date: &mut String, + usage_by_app: &mut UsageBuckets, + current_date: &str, +) { + if !is_stale_usage_date(tracked_date, current_date) { + return; + } + + *tracked_date = current_date.to_string(); + usage_by_app.clear(); +} + +pub(crate) fn load_usage_state(app: &AppHandle, current_date: &str) -> Result { let path = usage_state_path(app)?; if !path.exists() { - return Ok(HashMap::new()); + return Ok(empty_usage_state(current_date)); } let raw = fs::read_to_string(path).map_err(|error| error.to_string())?; - serde_json::from_str::(&raw).map_err(|error| error.to_string()) + usage_state_from_json(&raw, current_date) } -pub(crate) fn save_usage_by_app( - app: &AppHandle, - usage_by_app: &UsageBuckets, -) -> Result<(), String> { +pub(crate) fn save_usage_state(app: &AppHandle, usage_state: &UsageState) -> Result<(), String> { let path = usage_state_path(app)?; let parent = path .parent() .ok_or_else(|| "failed to resolve usage state directory".to_string())?; fs::create_dir_all(parent).map_err(|error| error.to_string())?; - let payload = serde_json::to_string_pretty(usage_by_app).map_err(|error| error.to_string())?; + let payload = serde_json::to_string_pretty(usage_state).map_err(|error| error.to_string())?; fs::write(path, payload).map_err(|error| error.to_string()) } +fn usage_state_from_json(raw: &str, current_date: &str) -> Result { + match serde_json::from_str::(raw) { + Ok(state) if !is_stale_usage_date(&state.tracked_date, current_date) => Ok(state), + Ok(_) => Ok(empty_usage_state(current_date)), + Err(_) => serde_json::from_str::(raw) + .map(|usage_by_app| UsageState { + tracked_date: current_date.to_string(), + usage_by_app, + }) + .map_err(|error| error.to_string()), + } +} + +fn empty_usage_state(current_date: &str) -> UsageState { + UsageState { + tracked_date: current_date.to_string(), + usage_by_app: HashMap::new(), + } +} + +#[cfg(not(target_os = "windows"))] +fn date_key_from_unix_days(days_since_epoch: i64) -> String { + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_piece = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_piece + 2) / 5 + 1; + let month = month_piece + if month_piece < 10 { 3 } else { -9 }; + let adjusted_year = year + if month <= 2 { 1 } else { 0 }; + format!("{adjusted_year:04}-{month:02}-{day:02}") +} + fn usage_state_path(app: &AppHandle) -> Result { let dir = app .path() @@ -67,3 +144,73 @@ fn usage_state_path(app: &AppHandle) -> Result { .map_err(|_| "failed to resolve application data directory".to_string())?; Ok(dir.join(USAGE_STATE_FILE)) } + +#[cfg(test)] +mod tests { + use super::{ + reset_usage_for_new_date, usage_state_from_json, UsageBucket, UsageBuckets, UsageState, + }; + + fn sample_bucket(seconds: u64) -> UsageBucket { + UsageBucket { + app_name: "Code".to_string(), + seconds, + window_title: Some("Focturn".to_string()), + } + } + + fn sample_usage() -> UsageBuckets { + UsageBuckets::from([("Code".to_string(), sample_bucket(42))]) + } + + #[test] + fn same_day_usage_state_is_preserved() { + let raw = serde_json::to_string(&UsageState { + tracked_date: "2026-05-17".to_string(), + usage_by_app: sample_usage(), + }) + .expect("usage state should serialize"); + + let state = + usage_state_from_json(&raw, "2026-05-17").expect("same-day usage state should load"); + + assert_eq!(state.tracked_date, "2026-05-17"); + assert_eq!(state.usage_by_app["Code"].seconds, 42); + } + + #[test] + fn previous_day_usage_state_is_reset() { + let raw = serde_json::to_string(&UsageState { + tracked_date: "2026-05-16".to_string(), + usage_by_app: sample_usage(), + }) + .expect("usage state should serialize"); + + let state = usage_state_from_json(&raw, "2026-05-17") + .expect("stale usage state should load as empty"); + + assert_eq!(state.tracked_date, "2026-05-17"); + assert!(state.usage_by_app.is_empty()); + } + + #[test] + fn legacy_usage_map_migrates_to_today() { + let raw = serde_json::to_string(&sample_usage()).expect("legacy usage should serialize"); + + let state = usage_state_from_json(&raw, "2026-05-17").expect("legacy usage should migrate"); + + assert_eq!(state.tracked_date, "2026-05-17"); + assert_eq!(state.usage_by_app["Code"].seconds, 42); + } + + #[test] + fn day_rollover_clears_in_memory_usage() { + let mut tracked_date = "2026-05-16".to_string(); + let mut usage_by_app = sample_usage(); + + reset_usage_for_new_date(&mut tracked_date, &mut usage_by_app, "2026-05-17"); + + assert_eq!(tracked_date, "2026-05-17"); + assert!(usage_by_app.is_empty()); + } +} diff --git a/apps/desktop/src/components/SettingsPanel.tsx b/apps/desktop/src/components/SettingsPanel.tsx index 5f8745a..4dbd523 100644 --- a/apps/desktop/src/components/SettingsPanel.tsx +++ b/apps/desktop/src/components/SettingsPanel.tsx @@ -103,11 +103,11 @@ export function SettingsPanel({ settings, isSaving, onChange, onReset }: Setting onChange={(event) => update({ monitoringEnabled: event.target.checked })} /> - Monitoring active + Reminder active {settings.monitoringEnabled ? 'Focturn can show reminders and notifications' - : 'Reminder popup and notifications are disabled'} + : 'Reminder popup and notifications are off'}