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
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>
172 lines
6.6 KiB
Rust
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))
|
|
);
|
|
}
|
|
}
|