M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
4 changed files with 270 additions and 3 deletions
Showing only changes of commit f38864088b - Show all commits
+65
View File
@@ -755,6 +755,71 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The path the notification's Done button takes.
///
/// Completing a RECURRING reminder must move it, not end it — this is the
/// behaviour the web has had all along and the clients did not, which made
/// "Done" on a daily reminder quietly the last time it ever fired.
#[test]
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Water the plants", ""))
.expect("create");
let armed = app
.update_note(
note.id.clone(),
vec![
NoteEdit::RemindAt {
value: "2026-07-01T09:00:00.000Z".into(),
},
NoteEdit::Recurrence {
value: "daily".into(),
},
],
)
.expect("arm a daily reminder");
assert_eq!(armed.recurrence.as_deref(), Some("daily"));
let done = app.complete_reminder(note.id.clone()).expect("complete");
let next = done
.remind_at
.expect("a daily reminder must still have a next occurrence");
assert!(
next.as_str() > "2026-07-01T09:00:00.000Z",
"it must move FORWARD, got {next:?}"
);
assert!(
next.ends_with("T09:00:00.000Z"),
"the time of day is what was asked for and must survive, got {next:?}"
);
assert_eq!(
done.recurrence.as_deref(),
Some("daily"),
"the rule outlives the occurrence"
);
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
// invisibly on a note with no reminder.
let once = app
.create_note(draft("Post the letter", ""))
.expect("create");
app.update_note(
once.id.clone(),
vec![NoteEdit::RemindAt {
value: "2026-07-01T09:00:00.000Z".into(),
}],
)
.expect("arm a one-off");
let finished = app.complete_reminder(once.id.clone()).expect("complete");
assert_eq!(finished.remind_at, None);
assert_eq!(finished.recurrence, None);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
+1
View File
@@ -6,6 +6,7 @@
pub mod derive;
pub mod models;
pub mod recur;
pub mod retention;
pub mod schema;
pub mod store;
+171
View File
@@ -0,0 +1,171 @@
//! Recurring-reminder math: where a reminder goes when it is marked done.
//!
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept
//! behaviourally identical rather than merely similar. The same note can be
//! completed from the web (server code) or from the desktop and Android (this
//! code), and the two must land on the same instant — otherwise completing a
//! reminder on a phone and then syncing would silently move it relative to
//! completing it in a browser, and neither surface would look wrong on its own.
//!
//! Advancement is measured from the reminder's OWN time, never from now. That is
//! what keeps a 09:00 daily reminder at 09:00 after being dealt with at 09:47,
//! and a monthly one on the same day of the month.
//!
//! Known limitation, shared with the server: the arithmetic is in UTC, and a note
//! carries no timezone. So a daily reminder crossing a DST boundary keeps its UTC
//! time and shifts by an hour locally. Fixing that means storing a zone per note
//! and is a change to the wire format, not to this file.
use chrono::{DateTime, Duration, Months, Utc};
/// The four intervals every surface offers. Anything else is not a recurrence.
pub const RECURRENCES: [&str; 4] = ["daily", "weekly", "monthly", "yearly"];
/// A recurrence we recognise, or nothing.
///
/// Values reach the store from three clients and a sync payload, so "not a rule
/// we know" is an ordinary case rather than a corruption to shout about.
pub fn normalize(value: Option<&str>) -> Option<&str> {
value.filter(|v| RECURRENCES.contains(v))
}
/// One step forward. `None` for an unrecognised rule.
///
/// Months and years clamp the day to the target month's length — 31 January plus
/// a month is 28 February, and the following step is 28 March rather than back to
/// the 31st. `chrono`'s `checked_add_months` does that clamping, matching
/// `_add_months` in the Python to the day.
fn advance_once(at: DateTime<Utc>, recurrence: &str) -> Option<DateTime<Utc>> {
match recurrence {
"daily" => at.checked_add_signed(Duration::days(1)),
"weekly" => at.checked_add_signed(Duration::weeks(1)),
"monthly" => at.checked_add_months(Months::new(1)),
"yearly" => at.checked_add_months(Months::new(12)),
_ => None,
}
}
/// The first fire time strictly after `after`, rolling past anything missed.
///
/// A phone left in a drawer for a fortnight should not come back to fourteen
/// pending occurrences of the same daily reminder — it should come back to
/// tomorrow's. `None` when the rule is not one we know, which the caller reads as
/// "this reminder is finished".
pub fn next_occurrence(
remind_at: DateTime<Utc>,
recurrence: &str,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
let mut next = advance_once(remind_at, recurrence)?;
while next <= after {
match advance_once(next, recurrence) {
// The equality check is a guard against a step that does not move,
// which would spin here forever. It cannot happen with the four rules
// above; it is cheap insurance against a fifth that does not advance.
Some(step) if step != next => next = step,
_ => break,
}
}
Some(next)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn utc(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap()
}
/// Mirrors `test_normalize_recurrence` in `tests/test_notes.py`.
#[test]
fn only_the_four_known_rules_are_recurrences() {
for rule in RECURRENCES {
assert_eq!(normalize(Some(rule)), Some(rule));
}
assert_eq!(normalize(Some("none")), None);
assert_eq!(normalize(Some("")), None);
assert_eq!(normalize(Some("hourly")), None);
assert_eq!(normalize(None), None);
}
/// Mirrors `test_next_occurrence_daily_weekly`.
#[test]
fn daily_and_weekly_keep_the_time_of_day() {
let base = utc(2026, 7, 1, 9, 0);
let after = utc(2026, 7, 1, 12, 0);
assert_eq!(
next_occurrence(base, "daily", after),
Some(utc(2026, 7, 2, 9, 0))
);
assert_eq!(
next_occurrence(base, "weekly", after),
Some(utc(2026, 7, 8, 9, 0))
);
}
/// Mirrors `test_next_occurrence_skips_missed`.
#[test]
fn missed_occurrences_are_rolled_past_not_queued() {
let base = utc(2026, 7, 1, 9, 0);
let after = utc(2026, 7, 10, 12, 0);
assert_eq!(
next_occurrence(base, "daily", after),
Some(utc(2026, 7, 11, 9, 0))
);
}
/// Mirrors `test_next_occurrence_monthly_clamps_month_end`.
#[test]
fn monthly_clamps_to_a_shorter_month() {
let base = utc(2026, 1, 31, 8, 0);
let after = utc(2026, 2, 1, 0, 0);
assert_eq!(
next_occurrence(base, "monthly", after),
Some(utc(2026, 2, 28, 8, 0))
);
}
/// Mirrors `test_next_occurrence_yearly_and_none`.
#[test]
fn yearly_advances_a_year_and_an_unknown_rule_advances_nothing() {
let base = utc(2026, 3, 15, 7, 0);
let after = utc(2026, 3, 16, 0, 0);
assert_eq!(
next_occurrence(base, "yearly", after),
Some(utc(2027, 3, 15, 7, 0))
);
assert_eq!(next_occurrence(base, "none", after), None);
}
/// Not in the Python suite, and the one that would bite hardest in practice:
/// a monthly reminder set on the 31st must not walk itself back to the 28th
/// permanently. Each step is taken from the ORIGINAL date, so February's clamp
/// does not become March's date.
#[test]
fn a_clamped_month_does_not_drag_later_months_back() {
let base = utc(2026, 1, 31, 8, 0);
// Far enough ahead that the loop takes several steps.
let after = utc(2026, 4, 15, 0, 0);
// Jan 31 -> Feb 28 -> Mar 28 -> Apr 28. The clamp is sticky once applied,
// which matches the server exactly — asserted so a future "fix" to either
// side has to change both.
assert_eq!(
next_occurrence(base, "monthly", after),
Some(utc(2026, 4, 28, 8, 0))
);
}
/// A reminder completed before it was ever due still moves forward one step,
/// rather than staying put and firing again immediately.
#[test]
fn completing_early_still_advances() {
let base = utc(2026, 7, 10, 9, 0);
let after = utc(2026, 7, 1, 12, 0);
assert_eq!(
next_occurrence(base, "daily", after),
Some(utc(2026, 7, 11, 9, 0))
);
}
}
+33 -3
View File
@@ -7,13 +7,14 @@
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
//! up with the values the frontend sends.
use chrono::{Duration, SecondsFormat, Utc};
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::Value;
use uuid::Uuid;
use crate::local::derive;
use crate::local::models::*;
use crate::local::recur;
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
@@ -529,9 +530,38 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
load_note(conn, id)
}
/// Mark a reminder handled.
///
/// A recurring reminder advances to its next occurrence; a one-off clears both
/// `remind_at` AND `recurrence`. Clearing the rule as well matters: without it a
/// note whose recurrence is a value we do not recognise would keep that value
/// forever, invisible in every UI (they only render known rules) and waiting to
/// mean something the day the vocabulary grows.
///
/// Same behaviour as the server's `POST /<id>/reminder/complete`, deliberately —
/// the same note can be completed from a browser or from a client, and a
/// disagreement here would move a reminder depending on which one you used.
pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
// Clear the reminder. (Recurrence advancement is a later refinement.)
conn.execute("UPDATE notes SET remind_at = NULL WHERE id = ?1", [id])?;
let note = load_note(conn, id)?;
let next = note
.remind_at
.as_deref()
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
.and_then(|at| {
let rule = recur::normalize(note.recurrence.as_deref())?;
recur::next_occurrence(at.with_timezone(&Utc), rule, Utc::now())
});
match next {
Some(at) => conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id],
)?,
None => conn.execute(
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
[id],
)?,
};
touch(conn, id)?;
load_note(conn, id)
}