todo.recurrence-label
English text for a repeating todo's chip: "Every weekday", "Every 2 weeks on Monday", "Every month on the 31st".
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 30 tests, run in TypeScript, Python and Rust.
What it does
The English words for a repeating todo's chip, from its rule:
| rule | label | | --- | --- | | daily, 1 / 3 | Every day / Every 3 days | | weekdays | Every weekday | | weekly, 1 / 2 | Every week on Monday / Every 2 weeks on Monday | | monthly, 1 / 3 | Every month on the 31st / Every 3 months on the 1st | | yearly, 1 / 2 | Every year on 29 Feb / Every 2 years on 1 Jan |
For example
recurrenceLabel(frequency daily, interval 1, anchor 2026-09-28)→ Every day dailyrecurrenceLabel(frequency daily, interval 3, anchor 2026-09-28)→ Every 3 days every 3 daysrecurrenceLabel(frequency daily, interval 999, anchor 2026-09-28)→ Every 999 days the largest interval
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.
export function recurrenceLabel(recurrence: Recurrence): string
| recurrence | Recurrence | the rule; the weekday, day of month or date comes from its anchor |
| returns | string | e.g. "Every 3 days", "Every year on 29 Feb" |
Your code names it in one line, in the file that uses it
import { recurrenceLabel } from "#fune/todo.recurrence-label@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayName } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { type Recurrence } from "./todo_item.ts"; ← from todo.item ^1.0.0 · built alongside by fune
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const FREQUENCIES = ["daily", "weekdays", "weekly", "monthly", "yearly"];
/** 1st, 2nd, 3rd, 4th ... 11th, 12th, 13th ... 21st, 22nd, 23rd ... 31st. */
function ordinal(n: number): string {
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
const suffix = n % 10 === 1 ? "st" : n % 10 === 2 ? "nd" : n % 10 === 3 ? "rd" : "th";
return `${n}${suffix}`;
}
function every(interval: number, unit: string): string {
return interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
}
/**
* The English words for a repeat rule, for the chip next to a todo:
* "Every weekday", "Every 2 weeks on Monday", "Every month on the 31st",
* "Every year on 29 Feb". The weekday, day and date come from the anchor, the
* same anchor todo.next-occurrence counts from, and invalid rules fail with
* its messages.
*/
export function recurrenceLabel(recurrence: Recurrence): string {
const { frequency, interval, anchor } = recurrence;
if (!FREQUENCIES.includes(frequency)) {
throw new RangeError(`unknown repeat frequency "${frequency}": expected daily, weekdays, weekly, monthly or yearly`);
}
if (!Number.isInteger(interval) || interval < 1) {
throw new RangeError(`repeat interval must be 1 or more, received ${interval}`);
}
if (frequency === "weekdays" && interval !== 1) {
throw new RangeError(`weekday repeats cannot skip weeks: interval must be 1, received ${interval}`);
}
const date = parseIsoDate(anchor);
switch (frequency) {
case "daily":
return every(interval, "day");
case "weekdays":
return "Every weekday";
case "weekly":
return `${every(interval, "week")} on ${dayName(anchor)}`;
case "monthly":
return `${every(interval, "month")} on the ${ordinal(date.day)}`;
default:
return `${every(interval, "year")} on ${date.day} ${MONTHS[date.month - 1]}`;
}
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript 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.recurrence-label
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.recurrence-label-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.recurrence-label@1.0.0:typescript.
The whole function, every language, is one file too: todo.recurrence-label-1.0.0.fune, 14,770 bytes, sha256 48e103549743096fc75b9fabac022928dc7ddd1bad9a610567b1c5d64667ad4f. 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.recurrence-label
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.recurrence-label
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.recurrence-label
// fune: replace dates.day-of-week in todo.recurrence-label
// fune: replace todo.item in todo.recurrence-label
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.recurrence-label --steps.
// fune: step todo.recurrence-label 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 | |
|---|---|---|---|
| daily | frequency daily, interval 1, anchor 2026-09-28 | → | Every day |
| every 3 days | frequency daily, interval 3, anchor 2026-09-28 | → | Every 3 days |
| the largest interval | frequency daily, interval 999, anchor 2026-09-28 | → | Every 999 days |
| weekdays | frequency weekdays, interval 1, anchor 2026-09-28 | → | Every weekday |
| weekly from a Monday | frequency weekly, interval 1, anchor 2026-09-28 | → | Every week on Monday |
| every 2 weeks from a Sunday | frequency weekly, interval 2, anchor 2026-10-04 | → | Every 2 weeks on Sunday |
| weekly from a Friday | frequency weekly, interval 1, anchor 2026-10-02 | → | Every week on Friday |
| monthly on the 31st | frequency monthly, interval 1, anchor 2026-01-31 | → | Every month on the 31st |
| quarterly on the 1st | frequency monthly, interval 3, anchor 2026-10-01 | → | Every 3 months on the 1st |
| 2nd | frequency monthly, interval 1, anchor 2026-10-02 | → | Every month on the 2nd |
Show the other 20 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 3rd | frequency monthly, interval 1, anchor 2026-10-03 | → | Every month on the 3rd |
| 4th | frequency monthly, interval 1, anchor 2026-10-04 | → | Every month on the 4th |
| 11th, not 11st | frequency monthly, interval 1, anchor 2026-10-11 | → | Every month on the 11th |
| 12th, not 12nd | frequency monthly, interval 1, anchor 2026-10-12 | → | Every month on the 12th |
| 13th, not 13rd | frequency monthly, interval 1, anchor 2026-10-13 | → | Every month on the 13th |
| 21st | frequency monthly, interval 1, anchor 2026-10-21 | → | Every month on the 21st |
| 22nd | frequency monthly, interval 2, anchor 2026-10-22 | → | Every 2 months on the 22nd |
| 23rd | frequency monthly, interval 1, anchor 2026-10-23 | → | Every month on the 23rd |
| 30th | frequency monthly, interval 1, anchor 2026-09-30 | → | Every month on the 30th |
| yearly on a leap day | frequency yearly, interval 1, anchor 2024-02-29 | → | Every year on 29 Feb |
| every 2 years on New Year's Day | frequency yearly, interval 2, anchor 2027-01-01 | → | Every 2 years on 1 Jan |
| yearly at Christmas | frequency yearly, interval 1, anchor 2026-12-25 | → | Every year on 25 Dec |
| an unknown frequency | frequency fortnightly, interval 1, anchor 2026-09-28 | → | error: unknown repeat frequency "fortnightly": expected daily, weekdays, weekly, monthly or yearly |
| interval 0 | frequency daily, interval 0, anchor 2026-09-28 | → | error: repeat interval must be 1 or more, received 0 |
| a negative interval | frequency weekly, interval -2, anchor 2026-09-28 | → | error: repeat interval must be 1 or more, received -2 |
| a fractional interval | frequency daily, interval 1.5, anchor 2026-09-28 | → | error: repeat interval must be 1 or more, received 1.5 |
| weekdays cannot skip weeks | frequency weekdays, interval 2, anchor 2026-09-28 | → | error: weekday repeats cannot skip weeks: interval must be 1, received 2 |
| an anchor that never existed | frequency monthly, interval 1, anchor 2026-02-30 | → | error: "2026-02-30" is not a real calendar date |
| a malformed anchor, even for a daily rule | frequency daily, interval 1, anchor 2026-9-28 | → | error: "2026-9-28" is not an ISO date (YYYY-MM-DD) |
| an anchor with a trailing newline | frequency yearly, interval 1, anchor 2026-09-28 | → | error: is not an ISO date (YYYY-MM-DD) |
More from the author
The weekday, day of the month and date all come from the anchor, the date todo.next-occurrence counts every occurrence from (weekday via dates.day-of-week). Ordinals are 1st 2nd 3rd, 4th to 20th (so 11th, 12th, 13th), then 21st 22nd 23rd and 31st.
A monthly rule on the 29th, 30th or 31st falls on the last day of shorter months, and a yearly rule on 29 Feb falls on 28 Feb outside leap years (todo.next-occurrence); the label names the anchor's day and does not repeat that caveat, so an app that wants to say it should add it.
Invalid rules fail with todo.next-occurrence's messages: an unknown frequency, an interval below 1 (or not a whole number), a weekday rule with an interval other than 1, and an anchor that is not a real ISO date (dates.add-days's messages). Like todo.next-occurrence it does not enforce todo.item's upper limit of 999; todo.item's validators do.
Files
| Path | Bytes |
|---|---|
| README.md | 1,276 |
| impl/python.py | 1,824 |
| impl/rust.rs | 2,438 |
| impl/typescript.ts | 2,112 |
| vectors.json | 4,536 |