Functional Weave
Code in Python

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 28th
  • next_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 28th
  • next_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.

def next_occurrence(recurrence: Recurrence, due: str, today: str) -> str
recurrenceRecurrencehow the todo repeats, with the anchor every occurrence is counted from
duedatethe due date of the occurrence being completed
todaydatethe day it is completed
returnsdatethe first date of the series strictly after both due and today

Your code names it in one line, in the file that uses it

from fune.todo.next_occurrence import next_occurrence  # todo.next-occurrence@^1
impl/python.py · 34 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .dates_add_days import add_days  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week  ← from dates.day-of-week ^1.0.0 · built alongside by fune
from .subscriptions_next_billing_date import next_billing_date  ← from subscriptions.next-billing-date ^1.0.0 · built alongside by fune
from .todo_item import Recurrence  ← from todo.item ^1.0.0 · built alongside by fune

_STEPS = {"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}


def next_occurrence(recurrence: Recurrence, due: str, today: str) -> str:
    """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.
    """
    frequency, interval, anchor = recurrence.frequency, recurrence.interval, recurrence.anchor
    if frequency != "weekdays" and frequency not in _STEPS:
        raise ValueError(f'unknown repeat frequency "{frequency}": expected daily, weekdays, weekly, monthly or yearly')
    if isinstance(interval, bool) or not isinstance(interval, int) or interval < 1:
        raise ValueError(f"repeat interval must be 1 or more, received {interval}")
    if frequency == "weekdays" and interval != 1:
        raise ValueError(f"weekday repeats cannot skip weeks: interval must be 1, received {interval}")
    add_days(anchor, 0)
    add_days(due, 0)
    add_days(today, 0)
    if anchor > due:
        raise ValueError(f"repeat anchor {anchor} is after the due date {due}")

    after = due if due > today else today
    if frequency == "weekdays":
        nxt = add_days(after, 1)
        while day_of_week(nxt) > 5:
            nxt = add_days(nxt, 1)
        return nxt
    return next_billing_date(anchor, _STEPS[frequency], interval, after)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python 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. Or pin a range in fune.project and build in one step:

fune add todo.next-occurrence
Download for Python todo.next-occurrence-1.0.0-python.fune · 10,575 bytes sha256 2fb49aca4c29e27acab8ca37507bec673137ad38dece63ab4afb8ebb85e154ef

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./todo.next-occurrence-1.0.0-python.fune, or fetch it from a terminal with fune pull todo.next-occurrence@1.0.0:python.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,707
impl/python.py1,599
impl/rust.rs2,227
impl/typescript.ts2,050
vectors.json4,785