Files
inkwell/core/src/local/recur.rs
T
bvandeusenandClaude Opus 5.5 a706644455
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 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s
rename: the server is Inkwell — package, env vars, image, compose, export marker
Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:

- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
  commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
  CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
  thoughtsync-named release assets, because the lanes that publish them are
  renamed in steps 3 and 4
- exports are written with app "inkwell"

Two deliberate exceptions, both because data rides on them:

- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
  INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
  volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
  rename are backups. Tested both ways

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:18:49 -04:00

172 lines
6.6 KiB
Rust

//! Recurring-reminder math: where a reminder goes when it is marked done.
//!
//! A deliberate port of the server's `src/inkwell/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))
);
}
}