desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s

A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.

Core gains store::due_reminders, compared by instant, not by string.

Refs #5171

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:46:02 -04:00
co-authored by Claude Opus 5.5
parent 5989ffc1c6
commit df85535ea2
9 changed files with 942 additions and 25 deletions
+13
View File
@@ -95,6 +95,19 @@ pub struct TitleEntry {
pub title: String,
}
/// A reminder that has come due, as the desktop's reminder worker needs it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DueReminder {
pub id: String,
pub title: String,
/// `remind_at` as stored. It names the occurrence: completing or snoozing a
/// reminder changes it, and that is how an announcer tells a new occurrence
/// from one it has already announced.
pub remind_at: String,
/// The same instant, in milliseconds since the epoch.
pub due_ms: i64,
}
#[derive(Serialize)]
pub struct SavedFilter {
pub id: String,
+74
View File
@@ -375,6 +375,41 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
ids.iter().map(|id| load_note(conn, id)).collect()
}
/// Reminders due at or before `now_ms`, soonest first, trash excluded.
///
/// Read on a timer by the desktop's reminder worker, so only the notes that are
/// actually due are loaded in full (for their title). A `remind_at` that doesn't
/// parse is left out: it can never come due, and announcing it would be a guess.
pub fn due_reminders(conn: &Connection, now_ms: i64) -> rusqlite::Result<Vec<DueReminder>> {
let set: Vec<(String, String)> = {
let mut stmt = conn.prepare(
"SELECT id, remind_at FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<rusqlite::Result<_>>()?
};
let mut due = Vec::new();
for (id, remind_at) in set {
let Ok(at) = DateTime::parse_from_rfc3339(&remind_at) else {
continue;
};
let due_ms = at.timestamp_millis();
if due_ms > now_ms {
continue;
}
let title = load_note(conn, &id)?.display_title;
due.push(DueReminder {
id,
title,
remind_at,
due_ms,
});
}
// By instant, not by string: a synced `+00:00` and a local `Z` sort apart.
due.sort_by_key(|d| d.due_ms);
Ok(due)
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
// Names come from `load_note` so the palette and the card can never disagree
// about what a note is called. The command palette reads this; correctness
@@ -1521,6 +1556,45 @@ mod tests {
);
}
#[test]
fn only_reminders_already_due_are_due() {
let conn = db();
let past = note(&conn, "past");
let future = note(&conn, "future");
let binned = note(&conn, "binned");
update_note(
&conn,
&past.id,
&json!({"remind_at": "2026-03-01T09:00:00.000Z"}),
)
.expect("remind");
update_note(
&conn,
&future.id,
&json!({"remind_at": "2026-03-01T11:00:00.000Z"}),
)
.expect("remind");
update_note(
&conn,
&binned.id,
&json!({"remind_at": "2026-03-01T08:00:00.000Z"}),
)
.expect("remind");
trash(&conn, &binned.id).expect("trash");
let ten = DateTime::parse_from_rfc3339("2026-03-01T10:00:00Z")
.expect("rfc3339")
.timestamp_millis();
let due = due_reminders(&conn, ten).expect("due");
assert_eq!(due.len(), 1);
assert_eq!(due[0].id, past.id);
assert_eq!(due[0].title, past.display_title);
assert_eq!(due[0].remind_at, "2026-03-01T09:00:00.000Z");
assert_eq!(due[0].due_ms, ten - 3_600_000);
// At the instant itself it is due.
assert_eq!(due_reminders(&conn, ten + 3_600_000).expect("due").len(), 2);
}
// ---- labels ----------------------------------------------------------------
#[test]