Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions apps/desktop/src-tauri/src/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@ pub struct MonitorStore(Arc<Mutex<MonitorRuntime>>);
struct MonitorRuntime {
settings: AppSettings,
idle_state: idle::IdleState,
usage_date: String,
usage_by_app: UsageBuckets,
status: ReminderStatus,
snooze_until: Option<u64>,
Expand Down Expand Up @@ -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, &current_date) {
Ok(usage_state) => usage_state,
Err(error) => {
if let Ok(mut runtime) = self.0.lock() {
runtime.error_message = Some(error);
Expand All @@ -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);
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
&current_local_date_key(),
);

if !self.settings.monitoring_enabled {
self.is_reminder_visible = false;
self.snooze_until = None;
Expand Down Expand Up @@ -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;
Expand Down
163 changes: 155 additions & 8 deletions apps/desktop/src-tauri/src/monitor_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ pub(crate) struct UsageBucket {
pub(crate) window_title: Option<String>,
}

#[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<AppUsageEntry> {
let mut entries = usage_by_app.values().cloned().collect::<Vec<_>>();
entries.sort_by_key(|entry| Reverse(entry.seconds));
Expand All @@ -37,33 +44,173 @@ pub(crate) fn usage_entries(usage_by_app: &UsageBuckets, total_seconds: u64) ->
.collect()
}

pub(crate) fn load_usage_by_app(app: &AppHandle) -> Result<UsageBuckets, String> {
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)
Comment on lines +59 to +63
}
}

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<UsageState, String> {
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::<UsageBuckets>(&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<UsageState, String> {
match serde_json::from_str::<UsageState>(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::<UsageBuckets>(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<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.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());
}
}
4 changes: 2 additions & 2 deletions apps/desktop/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ export function SettingsPanel({ settings, isSaving, onChange, onReset }: Setting
onChange={(event) => update({ monitoringEnabled: event.target.checked })}
/>
<span>
<strong>Monitoring active</strong>
<strong>Reminder active</strong>
<small>
{settings.monitoringEnabled
? 'Focturn can show reminders and notifications'
: 'Reminder popup and notifications are disabled'}
: 'Reminder popup and notifications are off'}
Comment on lines +106 to +110
</small>
</span>
</label>
Expand Down
Loading