todo.next-occurrence
The next due date of a repeating todo once it is done: daily, weekdays, weekly, monthly or yearly, month-end safe.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 24 tests, run in TypeScript, Python and Rust.
What it does
When a repeating todo is done, the date it is due next. todo.list's `toggleTodo` calls it to roll a repeating todo forward.
## The series
For example
next_occurrence(frequency monthly, interval 1, anchor 2026-01-31, 2026-01-31, 2026-01-31)→ 2026-02-28 monthly from 31 January: February's due date is clamped to the 28thnext_occurrence(frequency monthly, interval 1, anchor 2026-01-31, 2026-02-28, 2026-02-28)→ 2026-03-31 monthly from 31 January, done on 28 February: back to the 31st, not the 28thnext_occurrence(frequency monthly, interval 1, anchor 2028-01-31, 2028-01-31, 2028-01-31)→ 2028-02-29 monthly from 31 January in a leap year: 29 February
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn next_occurrence(recurrence: &Recurrence, due: &str, today: &str) -> String
| recurrence | Recurrence | how the todo repeats, with the anchor every occurrence is counted from |
| due | date | the due date of the occurrence being completed |
| today | date | the day it is completed |
| returns | date | the first date of the series strictly after both due and today |
Your code names it in one line, in the file that uses it
fune!(todo.next-occurrence@^1); // then call next_occurrence(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::add_days; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week; ← from dates.day-of-week ^1.0.0 · built alongside by fune
use super::subscriptions_next_billing_date::next_billing_date; ← from subscriptions.next-billing-date ^1.0.0 · built alongside by fune
use super::todo_item::{recurrence_from_value, Recurrence}; ← from todo.item ^1.0.0 · built alongside by fune
/// The first date of the series strictly after both `due` and `today`.
///
/// Every occurrence is the anchor plus whole intervals, so month ends stay
/// month ends; finishing late skips the occurrences already missed.
///
/// # Panics
/// Panics on an unknown frequency, an interval below 1 (or above 1 for
/// weekdays), an impossible date, or an anchor after the due date.
pub fn next_occurrence(recurrence: &Recurrence, due: &str, today: &str) -> String {
let frequency = recurrence.frequency.as_str();
let interval = recurrence.interval;
let step = match frequency {
"daily" => "day",
"weekly" => "week",
"monthly" => "month",
"yearly" => "year",
"weekdays" => "",
other => panic!(
"unknown repeat frequency \"{}\": expected daily, weekdays, weekly, monthly or yearly",
other
),
};
if interval < 1 {
panic!("repeat interval must be 1 or more, received {}", interval);
}
if frequency == "weekdays" && interval != 1 {
panic!("weekday repeats cannot skip weeks: interval must be 1, received {}", interval);
}
add_days(&recurrence.anchor, 0);
add_days(due, 0);
add_days(today, 0);
if recurrence.anchor.as_str() > due {
panic!("repeat anchor {} is after the due date {}", recurrence.anchor, due);
}
let after = if due > today { due } else { today };
if frequency == "weekdays" {
let mut next = add_days(after, 1);
while day_of_week(&next) > 5 {
next = add_days(&next, 1);
}
return next;
}
next_billing_date(&recurrence.anchor, step, interval, after)
}
pub fn fune_vector(args: &[Value]) -> Value {
if let Value::Float(f) = args[0].get("interval") {
panic!("repeat interval must be 1 or more, received {}", f);
}
let rule = recurrence_from_value(&args[0]).expect("a recurrence");
Value::Str(next_occurrence(&rule, args[1].as_str(), args[2].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add todo.next-occurrence
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.next-occurrence-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.next-occurrence@1.0.0:rust.
The whole function, every language, is one file too: todo.next-occurrence-1.0.0.fune, 15,072 bytes, sha256 7a616d4b47583508c619f5160c68a8331ae3c067991b69c5ff71283ffc3a8ff1. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before todo.next-occurrence
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.next-occurrence
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.add-days in todo.next-occurrence
// fune: replace dates.day-of-week in todo.next-occurrence
// fune: replace subscriptions.next-billing-date in todo.next-occurrence
// fune: replace todo.item in todo.next-occurrence
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.next-occurrence --steps.
// fune: step todo.next-occurrence after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| monthly from 31 January: February's due date is clamped to the 28th | frequency monthly, interval 1, anchor 2026-01-31, 2026-01-31, 2026-01-31 | → | 2026-02-28 |
| monthly from 31 January, done on 28 February: back to the 31st, not the 28th | frequency monthly, interval 1, anchor 2026-01-31, 2026-02-28, 2026-02-28 | → | 2026-03-31 |
| monthly from 31 January in a leap year: 29 February | frequency monthly, interval 1, anchor 2028-01-31, 2028-01-31, 2028-01-31 | → | 2028-02-29 |
| quarterly from 31 January, done on 30 April: 31 July | frequency monthly, interval 3, anchor 2026-01-31, 2026-04-30, 2026-04-30 | → | 2026-07-31 |
| yearly on 29 February falls on 28 February in an ordinary year | frequency yearly, interval 1, anchor 2024-02-29, 2024-02-29, 2024-02-29 | → | 2025-02-28 |
| yearly on 29 February returns to the 29th in the next leap year | frequency yearly, interval 1, anchor 2024-02-29, 2027-02-28, 2027-02-28 | → | 2028-02-29 |
| yearly on New Year's Eve crosses into the next year | frequency yearly, interval 1, anchor 2026-12-31, 2026-12-31, 2026-12-31 | → | 2027-12-31 |
| daily, done on the day: tomorrow | frequency daily, interval 1, anchor 2026-09-28, 2026-09-28, 2026-09-28 | → | 2026-09-29 |
| daily, done eight days late: tomorrow, the missed days are skipped | frequency daily, interval 1, anchor 2026-09-20, 2026-09-20, 2026-09-28 | → | 2026-09-29 |
| every 3 days from the 20th, done on the 28th: the 29th (20, 23, 26, 29) | frequency daily, interval 3, anchor 2026-09-20, 2026-09-20, 2026-09-28 | → | 2026-09-29 |
Show the other 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| every 3 days from the 20th, done on the 29th itself: 2 October | frequency daily, interval 3, anchor 2026-09-20, 2026-09-26, 2026-09-29 | → | 2026-10-02 |
| monthly bill due 1 October paid early on 28 September: 1 November | frequency monthly, interval 1, anchor 2026-10-01, 2026-10-01, 2026-09-28 | → | 2026-11-01 |
| every other week from Monday 7 September, done on 21 September: 5 October | frequency weekly, interval 2, anchor 2026-09-07, 2026-09-21, 2026-09-21 | → | 2026-10-05 |
| weekly, done three days late: the next one after today, a week after the missed one | frequency weekly, interval 1, anchor 2026-09-21, 2026-09-21, 2026-09-24 | → | 2026-09-28 |
| weekdays, done on Friday 2 October: Monday 5 October | frequency weekdays, interval 1, anchor 2026-09-28, 2026-10-02, 2026-10-02 | → | 2026-10-05 |
| weekdays, due Saturday: Monday | frequency weekdays, interval 1, anchor 2026-09-28, 2026-10-03, 2026-10-03 | → | 2026-10-05 |
| weekdays, due Monday done Wednesday: Thursday | frequency weekdays, interval 1, anchor 2026-09-28, 2026-09-28, 2026-09-30 | → | 2026-10-01 |
| an interval of 0 | frequency daily, interval 0, anchor 2026-09-28, 2026-09-28, 2026-09-28 | → | error: repeat interval must be 1 or more |
| a fractional interval | frequency daily, interval 1.5, anchor 2026-09-28, 2026-09-28, 2026-09-28 | → | error: repeat interval must be 1 or more |
| weekdays every other week | frequency weekdays, interval 2, anchor 2026-09-28, 2026-09-28, 2026-09-28 | → | error: weekday repeats cannot skip weeks |
| an unknown frequency | frequency hourly, interval 1, anchor 2026-09-28, 2026-09-28, 2026-09-28 | → | error: unknown repeat frequency "hourly" |
| a due date that never existed | frequency daily, interval 1, anchor 2026-02-01, 2026-02-30, 2026-02-01 | → | error: is not a real calendar date |
| today in the wrong format | frequency daily, interval 1, anchor 2026-09-28, 2026-09-28, 28/09/2026 | → | error: is not an ISO date |
| an anchor after the due date | frequency monthly, interval 1, anchor 2026-10-01, 2026-09-01, 2026-09-01 | → | error: repeat anchor 2026-10-01 is after the due date 2026-09-01 |
More from the author
Every occurrence is the recurrence's `anchor` (the first due date of the series) plus a whole number of intervals, computed with subscriptions.next-billing-date: `daily` steps in days, `weekly` in 7 days, `monthly` and `yearly` in calendar months with the month-end clamp. So a monthly todo anchored on 31 January is due 28 February, 31 March, 30 April; the classic bug is to add a month to the previous due date, which leaves it on the 28th for ever after February. A yearly todo anchored on 29 February is due 28 February in ordinary years and 29 February again in leap years. An interval of 2 on `weekly` is every other week, counted from the anchor's week.
`weekdays` is the next Monday to Friday. It has no interval other than 1 and its anchor does not matter.
## Which occurrence
The answer is the first date of the series strictly after both `due` and `today`:
- done on the day it is due: the next one (daily: tomorrow); - done early (a monthly bill due 1 October paid on 28 September): the one after the due date, 1 November, not 1 October again; - done late (a daily todo five days overdue): the next one after today, tomorrow. The missed occurrences are skipped rather than left overdue, which is what every to-do app does with a habit you catch up on.
`today` is an argument, never the clock: the app passes the user's local date.
## Errors
An unknown frequency, an interval below 1, a weekdays repeat with any other interval, an impossible date (dates.add-days's message) and an anchor after the due date are errors.
Files
| Path | Bytes |
|---|---|
| README.md | 1,707 |
| impl/python.py | 1,599 |
| impl/rust.rs | 2,227 |
| impl/typescript.ts | 2,050 |
| vectors.json | 4,785 |