use rusqlite::Connection;
use serde_json::json;
use std::process::Command as StdCommand;
use anyhow::Result;
use horae_core::i18n::Lang;
use horae_core::model::task::{Status, Task};
use horae_core::repo::alarm;
use horae_core::repo::{tags, tasks};
use horae_core::schedule::effective_due;
use horae_core::time;
use horae_core::tr;
pub const LEAD_MINUTES: i64 = 5;
const PRUNE_MS: i64 = 7 * 24 * 3600 * 1000;
const CLASS_SOON: &str = "alarm-soon";
const CLASS_NOW: &str = "alarm-now";
const CLASS_OVERDUE: &str = "alarm-overdue";
const CLASS_NONE: &str = "alarm-none";
const WAYBAR_SYNC_SIGNAL: &str = "-RTMIN+12";
fn refresh_waybar() {
let _ = StdCommand::new("pkill")
.args([WAYBAR_SYNC_SIGNAL, "-x", "waybar"])
.status();
}
fn occ_key(id: &str, occ_ms: i64) -> String {
format!("{}:{}", id, occ_ms)
}
fn window(tasks: &[Task], skipped: &[String], limit: usize) -> Vec<(String, i64)> {
let mut v: Vec<(String, i64)> = tasks
.iter()
.filter(|t| t.status != Status::Done)
.filter_map(|t| effective_due(t).map(|d| (t.id.clone(), d)))
.filter(|(id, d)| !skipped.contains(&occ_key(id, *d)))
.collect();
v.sort_by_key(|(_, d)| *d);
v.truncate(limit.max(1));
v
}
fn window_keys(win: &[(String, i64)]) -> Vec<String> {
win.iter().map(|(id, d)| occ_key(id, *d)).collect()
}
fn due_to_ring(tasks: &[Task], rung: &[String], now: i64, lead_ms: i64) -> Vec<(String, i64)> {
tasks
.iter()
.filter(|t| t.status != Status::Done)
.filter_map(|t| effective_due(t).map(|d| (t.id.clone(), d)))
.filter(|(id, d)| now >= d - lead_ms && !rung.contains(&occ_key(id, *d)))
.collect()
}
pub(crate) fn format_alarm_message(t: &Task, d: i64, now: i64, lang: Lang) -> (String, String) {
let is_start = t.due_at.is_none() && t.scheduled_start_at.is_some();
let time_str = time::format_local(Some(d));
if d < now {
let overdue_mins = (now - d) / 60_000;
if overdue_mins >= 1 {
let label = if is_start {
tr!(lang, "计划开始", "Start")
} else {
tr!(lang, "截止", "Due")
};
let summary = tr!(lang, "⚠️ 任务逾期提醒", "⚠️ Task Overdue Reminder");
let body = tr!(
lang,
"已逾期 {} 分钟:\n{}\n{}: {}",
"Overdue by {} mins:\n{}\n{}: {}",
overdue_mins,
t.title,
label,
time_str
);
(summary.to_string(), body)
} else {
let label = if is_start {
tr!(lang, "计划开始时间已到", "Scheduled start time reached")
} else {
tr!(lang, "截止时间已到", "Deadline reached")
};
let summary = tr!(lang, "⏰ 任务到点提醒", "⏰ Task Due Reminder");
let body = format!(
"{}:\n{}\n{}: {}",
label,
t.title,
tr!(lang, "时间", "Time"),
time_str
);
(summary.to_string(), body)
}
} else {
let mins_left = ((d - now + 59_999) / 60_000).max(1);
let action = if is_start {
tr!(lang, "开始", "starting")
} else {
tr!(lang, "截止", "due")
};
let label = if is_start {
tr!(lang, "计划开始", "Start")
} else {
tr!(lang, "截止", "Due")
};
let summary = tr!(lang, "⏰ 任务提醒", "⏰ Task Reminder");
let body = tr!(
lang,
"{} 分钟后{}:\n{}\n{}: {}",
"In {} mins ({}):\n{}\n{}: {}",
mins_left,
action,
t.title,
label,
time_str
);
(summary.to_string(), body)
}
}
pub fn waybar(slot: Option<usize>, limit: Option<usize>, emit_all: bool) -> Result<()> {
let slot = slot.unwrap_or(1).max(1);
let limit = limit.unwrap_or(2).clamp(1, 10);
let conn = horae_core::db::conn::open(None)?;
let all = tasks::list(
&conn,
&tasks::ListFilter {
status: None,
tags: Vec::new(),
query: None,
review_stale: false,
},
)?;
let now = time::now_ms();
let lead_ms = LEAD_MINUTES * 60 * 1000;
let mut state = alarm::get_state().unwrap_or_default();
let lang = Lang::detect(Some(&conn));
let win = window(&all, &state.skipped, limit);
let win_keys = window_keys(&win);
let window_changed = state.last_window != win_keys;
if slot == 1 {
let mut dirty = false;
for (id, d) in due_to_ring(&all, &state.rung, now, lead_ms) {
if let Ok(t) = tasks::get(&conn, &id) {
state.rung.push(occ_key(&id, d));
let (summary, body) = format_alarm_message(&t, d, now, lang);
crate::commands::pomo::notify_with_sound(
&summary,
&body,
horae_core::sound::SoundKind::TaskDue,
);
dirty = true;
}
}
let pre_len = state.rung.len() + state.skipped.len();
state.rung.retain(|k| key_fresh(k, now));
state.skipped.retain(|k| key_fresh(k, now));
let post_len = state.rung.len() + state.skipped.len();
dirty |= post_len != pre_len;
if window_changed {
state.last_window = win_keys;
dirty = true;
}
if dirty {
alarm::save_state(&state)?;
}
}
if window_changed && slot == 1 {
refresh_waybar();
}
if emit_all {
let items: Vec<serde_json::Value> = win
.iter()
.map(|(id, d)| alarm_item(&conn, id, *d, now, lead_ms, lang))
.collect::<Result<Vec<_>>>()?;
println!("{}", serde_json::to_string(&items)?);
return Ok(());
}
let item = win
.get(slot - 1)
.map(|(id, d)| alarm_item(&conn, id, *d, now, lead_ms, lang))
.transpose()?
.unwrap_or_else(empty_item);
println!("{}", serde_json::to_string(&item)?);
Ok(())
}
pub fn next(slot: Option<usize>, limit: Option<usize>) -> Result<()> {
let slot = slot.unwrap_or(1).max(1);
let limit = limit.unwrap_or(2).clamp(1, 10);
let conn = horae_core::db::conn::open(None)?;
let all = tasks::list(
&conn,
&tasks::ListFilter {
status: None,
tags: Vec::new(),
query: None,
review_stale: false,
},
)?;
let mut state = alarm::get_state().unwrap_or_default();
if let Some((id, d)) = window(&all, &state.skipped, limit).get(slot - 1) {
state.skipped.push(occ_key(id, *d));
alarm::save_state(&state)?;
refresh_waybar();
if let Ok(t) = tasks::get(&conn, id) {
println!("skipped: {}", t.title);
}
}
Ok(())
}
fn key_fresh(key: &str, now: i64) -> bool {
key.rsplit_once(':')
.is_some_and(|(_, m)| m.parse::<i64>().is_ok_and(|ms| ms >= now - PRUNE_MS))
}
fn alarm_item(
conn: &Connection,
id: &str,
due_ms: i64,
now: i64,
lead_ms: i64,
lang: Lang,
) -> Result<serde_json::Value> {
let t = tasks::get(conn, id)?;
let (class, marker) = if due_ms < now {
(CLASS_OVERDUE, "⏰")
} else if due_ms - now <= lead_ms {
(CLASS_NOW, "🔔")
} else {
(CLASS_SOON, "⏰")
};
let mut title = t.title.clone();
if title.chars().nth(14).is_some() {
let truncated: String = title.chars().take(13).collect();
title = format!("{}…", truncated);
}
let tags_s = tags::get_task_tags(conn, id)?
.iter()
.map(|x| x.name.as_str())
.collect::<Vec<_>>()
.join(",");
let tags_s = if tags_s.is_empty() {
"-"
} else {
tags_s.as_str()
};
let is_start = t.due_at.is_none() && t.scheduled_start_at.is_some();
let time_label = if is_start {
tr!(lang, "计划开始", "Start")
} else {
tr!(lang, "截止", "Due")
};
let time_type = if is_start { "start" } else { "due" };
Ok(json!({
"text": format!("{} {} {}", marker, compact_time(due_ms), title),
"alt": class,
"class": class,
"tooltip": format!(
"{}\n{}: {}\n{}: {}\n{}: {}",
t.title,
tr!(lang, "状态", "Status"),
t.status,
time_label,
time::format_local(Some(due_ms)),
tr!(lang, "标签", "Tags"),
tags_s
),
"id": t.id,
"title": t.title,
"due": due_ms,
"due_at": t.due_at,
"scheduled_start_at": t.scheduled_start_at,
"time_type": time_type,
"is_start": is_start,
"status": format!("{}", t.status),
"tags": tags_s,
}))
}
fn empty_item() -> serde_json::Value {
json!({
"text": "⏰ --",
"alt": CLASS_NONE,
"class": CLASS_NONE,
"tooltip": "暂无即将到来的任务",
})
}
fn compact_time(ms: i64) -> String {
use chrono::{Local, TimeZone, Utc};
match Utc.timestamp_millis_opt(ms).single() {
Some(dt) => {
let local = dt.with_timezone(&Local);
if local.date_naive() == Local::now().date_naive() {
local.format("%H:%M").to_string()
} else {
local.format("%m-%d %H:%M").to_string()
}
}
None => ms.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_task(id: &str, status: Status, due: Option<i64>) -> Task {
Task {
id: id.into(),
title: format!("t-{}", id),
notes: String::new(),
status,
rrule: None,
priority: None,
created_at: 0,
clarified_at: None,
due_at: due,
scheduled_start_at: None,
scheduled_end_at: None,
completed_at: None,
archived_at: None,
archive_reason: None,
updated_at: 0,
delegated_to: None,
checklist: vec![],
}
}
#[test]
fn window_takes_two_nearest_and_skips_done() {
let tasks = vec![
mk_task("a", Status::Next, Some(3000)),
mk_task("b", Status::Next, Some(1000)),
mk_task("c", Status::Next, Some(2000)),
mk_task("d", Status::Done, Some(500)),
];
let w = window(&tasks, &[], 2);
assert_eq!(w.len(), 2);
assert_eq!(w[0], ("b".to_string(), 1000));
assert_eq!(w[1], ("c".to_string(), 2000));
}
#[test]
fn window_excludes_skipped_and_advances() {
let tasks = vec![
mk_task("a", Status::Next, Some(1000)),
mk_task("b", Status::Next, Some(2000)),
mk_task("c", Status::Next, Some(3000)),
];
let skipped = vec![occ_key("a", 1000)];
let w = window(&tasks, &skipped, 2);
assert_eq!(w.len(), 2);
assert_eq!(w[0], ("b".to_string(), 2000));
assert_eq!(w[1], ("c".to_string(), 3000));
}
#[test]
fn window_respects_limit() {
let tasks = vec![
mk_task("a", Status::Next, Some(1000)),
mk_task("b", Status::Next, Some(2000)),
mk_task("c", Status::Next, Some(3000)),
];
let one = window(&tasks, &[], 1);
assert_eq!(one, vec![("a".to_string(), 1000)]);
let three = window(&tasks, &[], 3);
assert_eq!(three.len(), 3);
}
#[test]
fn window_keys_match_same_window() {
let w = vec![("a".to_string(), 1000), ("b".to_string(), 2000)];
assert_eq!(
window_keys(&w),
vec![occ_key("a", 1000), occ_key("b", 2000)]
);
}
#[test]
fn rung_task_stays_in_window() {
let tasks = vec![
mk_task("a", Status::Next, Some(1000)),
mk_task("b", Status::Next, Some(2000)),
];
let w = window(&tasks, &[], 2);
assert_eq!(w[0].0, "a");
}
#[test]
fn alarm_item_embeds_task_fields() {
let (_dir, conn) = horae_core::testutil::test_conn();
let t = horae_core::repo::tasks::create_capture(
&conn,
&horae_core::repo::tasks::CaptureInput {
title: "提交周报".into(),
..Default::default()
},
)
.unwrap();
let due = 1_750_000_000_000;
let item = alarm_item(&conn, &t.id, due, 1_800_000_000_000, 300_000, Lang::Zh).unwrap();
assert_eq!(item["id"], t.id);
assert_eq!(item["title"], "提交周报");
assert_eq!(item["due"], due);
assert_eq!(item["status"], "inbox");
assert_eq!(item["class"], CLASS_OVERDUE);
assert!(item["tags"].is_string());
let t_sched = horae_core::repo::tasks::create_capture(
&conn,
&horae_core::repo::tasks::CaptureInput {
title: "晨会".into(),
..Default::default()
},
)
.unwrap();
horae_core::repo::tasks::schedule(&conn, &t_sched.id, due, None, None).unwrap();
let item_sched = alarm_item(
&conn,
&t_sched.id,
due,
1_800_000_000_000,
300_000,
Lang::Zh,
)
.unwrap();
assert_eq!(item_sched["is_start"], true);
assert_eq!(item_sched["time_type"], "start");
assert!(item_sched["tooltip"]
.as_str()
.unwrap()
.contains("计划开始:"));
}
#[test]
fn ring_fires_only_within_lead_window() {
let now = 10_000;
let lead_ms = 5 * 60 * 1000; let tasks = vec![
mk_task("soon", Status::Next, Some(now + 60_000)),
mk_task("just_out", Status::Next, Some(now + 360_000)),
mk_task("overdue", Status::Next, Some(now - 1000)),
];
let mut ring = due_to_ring(&tasks, &[], now, lead_ms);
ring.sort_by_key(|(_, due)| *due);
let ids: Vec<String> = ring.into_iter().map(|(id, _)| id).collect();
assert_eq!(ids, vec!["overdue", "soon"]);
}
#[test]
fn ring_skips_rung_occurrence() {
let now = 10_000;
let lead_ms = 5 * 60 * 1000;
let tasks = vec![mk_task("a", Status::Next, Some(now))];
let rung = vec![occ_key("a", now)];
assert!(due_to_ring(&tasks, &rung, now, lead_ms).is_empty());
}
#[test]
fn format_alarm_message_distinguishes_overdue_and_upcoming() {
let now = 1_000_000_000_000;
let mut t = mk_task("提交周报", Status::Next, Some(now - 120_000));
t.due_at = Some(now - 120_000);
let (summary, body) = format_alarm_message(&t, now - 120_000, now, Lang::Zh);
assert_eq!(summary, "⚠️ 任务逾期提醒");
assert!(body.contains("已逾期 2 分钟"));
assert!(body.contains("提交周报"));
assert!(body.contains("截止:"));
let (summary_en, body_en) = format_alarm_message(&t, now - 120_000, now, Lang::En);
assert_eq!(summary_en, "⚠️ Task Overdue Reminder");
assert!(body_en.contains("Overdue by 2 mins"));
let (summary2, body2) = format_alarm_message(&t, now + 180_000, now, Lang::Zh);
assert_eq!(summary2, "⏰ 任务提醒");
assert!(body2.contains("3 分钟后截止"));
t.due_at = None;
t.scheduled_start_at = Some(now + 60_000);
let (summary3, body3) = format_alarm_message(&t, now + 60_000, now, Lang::Zh);
assert_eq!(summary3, "⏰ 任务提醒");
assert!(body3.contains("1 分钟后开始"));
assert!(body3.contains("计划开始:"));
let (summary3_en, body3_en) = format_alarm_message(&t, now + 60_000, now, Lang::En);
assert_eq!(summary3_en, "⏰ Task Reminder");
assert!(body3_en.contains("In 1 mins (starting)"));
}
}