use rusqlite::Connection;
use serde_json::json;
use std::process::Command as StdCommand;
use anyhow::Result;
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;
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 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 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));
crate::commands::pomo::notify(
"⏰ 任务提醒",
&format!(
"5 分钟后要做:\n{}\n截止: {}",
t.title,
time::format_local(Some(d))
),
);
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))
.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))
.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,
) -> 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()
};
Ok(json!({
"text": format!("{} {} {}", marker, compact_time(due_ms), title),
"alt": class,
"class": class,
"tooltip": format!(
"{}\n状态: {}\n截止: {}\n标签: {}",
t.title,
t.status,
time::format_local(Some(due_ms)),
tags_s
),
"id": t.id,
"title": t.title,
"due": due_ms,
"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).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());
}
#[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());
}
}