use crate::context::AppContext;
use crate::model::{AlarmTrigger, DateType, Task};
use crate::storage::LocalStorage;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AlarmIndexEntry {
pub trigger_ms: i64,
pub task_uid: String,
pub alarm_uid: String,
pub task_title: String,
pub calendar_href: String,
pub is_implicit: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlarmIndex {
pub version: u32,
pub last_updated: i64,
pub alarms: Vec<AlarmIndexEntry>,
}
impl Default for AlarmIndex {
fn default() -> Self {
Self {
version: 2,
last_updated: Utc::now().timestamp(),
alarms: Vec::new(),
}
}
}
impl AlarmIndex {
fn get_path(ctx: &dyn AppContext) -> Option<std::path::PathBuf> {
ctx.get_alarm_index_path()
}
pub fn load(ctx: &dyn AppContext) -> Self {
let Some(path) = Self::get_path(ctx) else {
return Self::default();
};
if !path.exists() {
return Self::default();
}
LocalStorage::with_lock(&path, || {
let content = fs::read_to_string(&path)?;
let index: AlarmIndex = serde_json::from_str(&content)?;
if index.version != 2 {
return Ok(Self::default());
}
Ok(index)
})
.unwrap_or_else(|_| Self::default())
}
pub fn save(&self, ctx: &dyn AppContext) -> Result<()> {
let Some(path) = Self::get_path(ctx) else {
anyhow::bail!("Could not determine alarm index path");
};
LocalStorage::with_lock(&path, || {
let json = serde_json::to_string_pretty(&self)?;
LocalStorage::atomic_write(&path, json)?;
Ok(())
})
}
pub fn rebuild_from_tasks(
tasks: &HashMap<String, HashMap<String, Task>>,
auto_reminders_enabled: bool,
default_reminder_time: &str,
) -> Self {
use chrono::NaiveTime;
let mut alarms = Vec::new();
let now = Utc::now();
let default_time = NaiveTime::parse_from_str(default_reminder_time, "%H:%M")
.unwrap_or_else(|_| NaiveTime::from_hms_opt(9, 0, 0).unwrap());
for (calendar_href, task_map) in tasks {
if calendar_href == crate::storage::LOCAL_TRASH_HREF
|| calendar_href == "local://recovery"
{
continue;
}
for task in task_map.values() {
if task.status.is_done() || task.status == crate::model::TaskStatus::InProcess {
continue;
}
for alarm in &task.alarms {
if alarm.acknowledged.is_some() {
continue;
}
let trigger_dt = match alarm.trigger {
AlarmTrigger::Absolute(dt) => Some(dt),
AlarmTrigger::Relative(mins) => {
let anchor = if let Some(DateType::Specific(d)) = task.due {
Some(d)
} else if let Some(DateType::Specific(s)) = task.dtstart {
Some(s)
} else {
None
};
anchor.map(|a| a + chrono::Duration::minutes(mins as i64))
}
};
if let Some(trigger) = trigger_dt {
if trigger > now || (now - trigger).num_minutes() < 60 {
alarms.push(AlarmIndexEntry {
trigger_ms: trigger.timestamp_millis(),
task_uid: task.uid.clone(),
alarm_uid: alarm.uid.clone(),
task_title: task.summary.clone(),
calendar_href: calendar_href.clone(),
is_implicit: false,
description: alarm.description.clone(),
});
}
}
}
if auto_reminders_enabled {
let has_active_explicit = task.alarms.iter().any(|a| a.acknowledged.is_none());
if !has_active_explicit {
let mut add_implicit = |dt: DateTime<Utc>, desc: &str, type_key: &str| {
if dt > now || (now - dt).num_minutes() < 60 {
let trigger_ms = dt.timestamp_millis();
let exists = alarms
.iter()
.any(|a| a.task_uid == task.uid && a.trigger_ms == trigger_ms);
if exists {
return;
}
let ts_str = dt.to_rfc3339();
let synth_id =
format!("implicit_{}:|{}|{}", type_key, ts_str, task.uid);
alarms.push(AlarmIndexEntry {
trigger_ms,
task_uid: task.uid.clone(),
alarm_uid: synth_id,
task_title: task.summary.clone(),
calendar_href: calendar_href.clone(),
is_implicit: true,
description: Some(desc.to_string()),
});
}
};
if let Some(due) = &task.due {
let dt = due.to_utc_with_default_time(default_time);
add_implicit(dt, "Due now", "due");
}
if let Some(start) = &task.dtstart {
let dt = start.to_utc_with_default_time(default_time);
add_implicit(dt, "Starting now", "start");
}
}
}
}
}
alarms.sort_by_key(|a| a.trigger_ms);
alarms.dedup_by(|a, b| a.alarm_uid == b.alarm_uid);
Self {
version: 1,
last_updated: now.timestamp(),
alarms,
}
}
pub fn get_firing_alarms(&self) -> Vec<AlarmIndexEntry> {
let now = Utc::now();
let now_ms = now.timestamp_millis();
let grace_period_ms = 2 * 60 * 60 * 1000;
self.alarms
.iter()
.filter(|alarm| {
let trigger_ms = alarm.trigger_ms;
trigger_ms <= now_ms && (now_ms - trigger_ms) < grace_period_ms
})
.cloned()
.collect()
}
pub fn get_next_alarm_timestamp(&self) -> Option<u64> {
let now_ms = Utc::now().timestamp_millis();
#[cfg(target_os = "android")]
log::debug!(
"get_next_alarm_timestamp: checking {} alarms, now_ms={}",
self.alarms.len(),
now_ms
);
let result = self
.alarms
.iter()
.find(|alarm| alarm.trigger_ms > now_ms)
.map(|alarm| (alarm.trigger_ms / 1000) as u64);
#[cfg(target_os = "android")]
match result {
Some(ts) => log::debug!(
"get_next_alarm_timestamp: found next alarm at timestamp {} (in {} seconds)",
ts,
(ts as i64) - (now_ms / 1000)
),
None => log::debug!("get_next_alarm_timestamp: no future alarms found"),
}
result
}
pub fn len(&self) -> usize {
self.alarms.len()
}
pub fn is_empty(&self) -> bool {
self.alarms.is_empty()
}
pub fn prune_old_alarms(&mut self) {
let now_ms = Utc::now().timestamp_millis();
let grace_period_ms = 2 * 60 * 60 * 1000;
self.alarms
.retain(|alarm| now_ms - alarm.trigger_ms < grace_period_ms);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alarm_index_serialization() {
let index = AlarmIndex {
version: 1,
last_updated: 1234567890,
alarms: vec![AlarmIndexEntry {
trigger_ms: 1735689600000,
task_uid: "task-123".to_string(),
alarm_uid: "alarm-456".to_string(),
task_title: "Important meeting".to_string(),
calendar_href: "local".to_string(),
is_implicit: false,
description: Some("Don't forget!".to_string()),
}],
};
let json = serde_json::to_string(&index).unwrap();
let deserialized: AlarmIndex = serde_json::from_str(&json).unwrap();
assert_eq!(index.version, deserialized.version);
assert_eq!(index.alarms.len(), deserialized.alarms.len());
assert_eq!(index.alarms[0].task_uid, deserialized.alarms[0].task_uid);
}
#[test]
fn test_get_firing_alarms() {
let now = Utc::now();
let past = now - chrono::Duration::minutes(30);
let future = now + chrono::Duration::minutes(30);
let too_old = now - chrono::Duration::hours(2);
let index = AlarmIndex {
version: 1,
last_updated: now.timestamp(),
alarms: vec![
AlarmIndexEntry {
trigger_ms: past.timestamp_millis(),
task_uid: "task-1".to_string(),
alarm_uid: "alarm-1".to_string(),
task_title: "Should fire".to_string(),
calendar_href: "local".to_string(),
is_implicit: false,
description: None,
},
AlarmIndexEntry {
trigger_ms: future.timestamp_millis(),
task_uid: "task-2".to_string(),
alarm_uid: "alarm-2".to_string(),
task_title: "Should not fire yet".to_string(),
calendar_href: "local".to_string(),
is_implicit: false,
description: None,
},
AlarmIndexEntry {
trigger_ms: too_old.timestamp_millis(),
task_uid: "task-3".to_string(),
alarm_uid: "alarm-3".to_string(),
task_title: "Too old".to_string(),
calendar_href: "local".to_string(),
is_implicit: false,
description: None,
},
],
};
let firing = index.get_firing_alarms();
assert_eq!(firing.len(), 1);
assert_eq!(firing[0].task_uid, "task-1");
}
#[test]
fn test_prune_old_alarms() {
let now = Utc::now();
let past = now - chrono::Duration::minutes(30);
let too_old = now - chrono::Duration::hours(2);
let mut index = AlarmIndex {
version: 1,
last_updated: now.timestamp(),
alarms: vec![
AlarmIndexEntry {
trigger_ms: past.timestamp_millis(),
task_uid: "task-1".to_string(),
alarm_uid: "alarm-1".to_string(),
task_title: "Recent".to_string(),
calendar_href: "local".to_string(),
is_implicit: false,
description: None,
},
AlarmIndexEntry {
trigger_ms: too_old.timestamp_millis(),
task_uid: "task-2".to_string(),
alarm_uid: "alarm-2".to_string(),
task_title: "Old".to_string(),
calendar_href: "local".to_string(),
is_implicit: false,
description: None,
},
],
};
index.prune_old_alarms();
assert_eq!(index.alarms.len(), 1);
assert_eq!(index.alarms[0].task_uid, "task-1");
}
}