//! 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, recurrence: &str) -> Option> { 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, recurrence: &str, after: DateTime, ) -> Option> { 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.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)) ); } }