todo.parse-repeat
Read a Repeat box, "every 2 weeks" or "every monday on 2026-10-05", as a todo's recurrence, or null.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 22 tests, run in TypeScript, Python and Rust.
What it does
Reads what a person types into a todo's Repeat box as a `Recurrence` (todo.item), or null when it is not a repeat. It is the Repeat box's counterpart to todo.parse-date-phrase for the Due box.
`every 2 weeks` on a todo due Friday 2 October 2026 is `{frequency: weekly, interval: 2, anchor: 2026-10-02}`.
For example
parseRepeat(every week, —, 2026-09-28)→ frequency weekly, interval 1, anchor 2026-09-28 every week with no due date starts today, as quick-add wouldparseRepeat(every 2 weeks, 2026-10-02, 2026-09-28)→ frequency weekly, interval 2, anchor 2026-10-02 every 2 weeks starts on the todo's due dateparseRepeat(every month, 2026-01-31, 2026-01-20)→ frequency monthly, interval 1, anchor 2026-01-31 every month from a month-end due date keeps the 31st as the anchor
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 parseRepeat(text: string, due: string | null, today: string): Recurrence | null
| text | string | what was typed: an "every ..." phrase from todo.parse-quick-add's grammar, then optionally a start date |
| due | date? | the todo's due date, where the series starts unless the text names a date |
| today | date | the person's local date, which relative phrases count from |
| returns | Recurrence? | the recurrence, anchored on the series' first date (the todo's due date from then on); null when the text is not exactly that |
Your code names it in one line, in the file that uses it
import { parseRepeat } from "#fune/todo.parse-repeat@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } 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
import { parseDatePhrase } from "./todo_parse_date_phrase.ts"; ← from todo.parse-date-phrase ^1.0.0 · built alongside by fune
import { parseQuickAdd } from "./todo_parse_quick_add.ts"; ← from todo.parse-quick-add ^1.0.0 · built alongside by fune
function lowerAscii(text: string): string {
return text.replace(/[A-Z]/g, (c) => c.toLowerCase());
}
/**
* A Repeat box: an "every ..." phrase exactly as todo.parse-quick-add reads
* it, then optionally a date phrase for the start. Anything else, words left
* over included, is null. The series starts on the typed date, else on
* `due`, else where quick-add would start it; for "every <weekday>" and
* "every weekday" the start moves forward to the first day the pattern
* allows. The anchor is that first date.
*/
export function parseRepeat(text: string, due: string | null, today: string): Recurrence | null {
// addDays(x, 0) is the date check: it throws dates.add-days's message.
addDays(today, 0);
if (due !== null) addDays(due, 0);
const words = text.split(/[ \t\n\r\v\f]+/).filter((w) => w !== "");
if (words.length < 2 || lowerAscii(words[0]) !== "every") return null;
// The repeat is 2 words ("every week") or 3 ("every 2 weeks"); no 2-word
// form starts a 3-word one, so the first that parses as a bare repeat wins.
let repeat: Recurrence | null = null;
let length = 0;
for (const n of [2, 3]) {
if (n > words.length) break;
const draft = parseQuickAdd(words.slice(0, n).join(" "), today);
if (draft.recurrence !== null && draft.title === "") {
repeat = draft.recurrence;
length = n;
break;
}
}
if (repeat === null) return null;
let start = due;
if (words.length > length) {
start = parseDatePhrase(words.slice(length).join(" "), today);
if (start === null) return null;
}
if (start === null) return repeat;
if (repeat.frequency === "weekdays") {
const day = dayOfWeek(start);
if (day >= 6) start = addDays(start, 8 - day);
} else if (repeat.frequency === "weekly" && length === 2 && parseDatePhrase(words[1], today) !== null) {
// "every monday": quick-add anchored it on the next Monday, so that
// anchor's weekday is the one the series keeps.
start = addDays(start, (dayOfWeek(repeat.anchor) - dayOfWeek(start) + 7) % 7);
}
return { ...repeat, anchor: start };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 5 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.parse-repeat
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.parse-repeat-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.parse-repeat@1.0.0:typescript.
The whole function, every language, is one file too: todo.parse-repeat-1.0.0.fune, 16,279 bytes, sha256 efdd5ec36a56dc62104de85dc0453a6b348ac1d515dc1f5a5e1f6b7a9c048043. 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.parse-repeat
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.parse-repeat
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.parse-repeat
// fune: replace dates.day-of-week in todo.parse-repeat
// fune: replace todo.item in todo.parse-repeat
// fune: replace todo.parse-date-phrase in todo.parse-repeat
// fune: replace todo.parse-quick-add in todo.parse-repeat
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.parse-repeat --steps.
// fune: step todo.parse-repeat 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 | |
|---|---|---|---|
| every week with no due date starts today, as quick-add would | every week, —, 2026-09-28 | → | frequency weekly, interval 1, anchor 2026-09-28 |
| every 2 weeks starts on the todo's due date | every 2 weeks, 2026-10-02, 2026-09-28 | → | frequency weekly, interval 2, anchor 2026-10-02 |
| every month from a month-end due date keeps the 31st as the anchor | every month, 2026-01-31, 2026-01-20 | → | frequency monthly, interval 1, anchor 2026-01-31 |
| every Monday on a todo due Wednesday starts the next Monday, not weekly on Wednesdays | Every Monday, 2026-09-30, 2026-09-28 | → | frequency weekly, interval 1, anchor 2026-10-05 |
| every monday with no due date, typed on a Monday, starts next Monday | every monday, —, 2026-09-28 | → | frequency weekly, interval 1, anchor 2026-10-05 |
| every friday on a todo due that Friday starts that day | every friday, 2026-10-02, 2026-09-28 | → | frequency weekly, interval 1, anchor 2026-10-02 |
| every weekday on a todo due Saturday starts the Monday after | every weekday, 2026-10-03, 2026-09-28 | → | frequency weekdays, interval 1, anchor 2026-10-05 |
| every weekday typed on a Saturday with no due date starts Monday | every weekday, —, 2026-10-03 | → | frequency weekdays, interval 1, anchor 2026-10-05 |
| a date typed after the repeat beats the due date | every other month on 2026-10-15, 2026-10-01, 2026-09-28 | → | frequency monthly, interval 2, anchor 2026-10-15 |
| a relative start date counts from today | every 3 days tomorrow, 2026-10-10, 2026-09-28 | → | frequency daily, interval 3, anchor 2026-09-29 |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a two-word start date | every day next week, —, 2026-09-28 | → | frequency daily, interval 1, anchor 2026-10-05 |
| a weekday repeat with a start date moves to that weekday on or after it | every monday on wed, —, 2026-09-28 | → | frequency weekly, interval 1, anchor 2026-10-05 |
| every year from 29 February keeps the leap day as the anchor | every year, 2028-02-29, 2026-09-28 | → | frequency yearly, interval 1, anchor 2028-02-29 |
| case and extra spaces do not matter | EVERY day , 2026-10-01, 2026-09-28 | → | frequency daily, interval 1, anchor 2026-10-01 |
| words left over are not understood | every week please, 2026-10-01, 2026-09-28 | → | — |
| weekly on its own is a title word to quick-add, so not a repeat | weekly, 2026-10-01, 2026-09-28 | → | — |
| an empty box is not a repeat | , 2026-10-01, 2026-09-28 | → | — |
| every on its own is not a repeat | every, —, 2026-09-28 | → | — |
| an interval above 999 is not a repeat | every 1000 days, —, 2026-09-28 | → | — |
| a start date before the repeat is not understood | tomorrow every week, —, 2026-09-28 | → | — |
| a malformed today is an error | every week, —, 28/09/2026 | → | error: is not an ISO date |
| an impossible due date is an error | every week, 2026-02-30, 2026-09-28 | → | error: is not a real calendar date |
More from the author
## What it reads
The whole box must be one `every ...` phrase, then optionally a start date:
- **The repeat** is todo.parse-quick-add's grammar, exactly (the capability calls it rather than keeping a copy): `every day`, `every weekday`, `every week`, `every monday` (any weekday name), `every month`, `every year`, `every other week`, `every 3 days`, `every ten years` (1 to 999). Case-insensitive, ASCII whitespace between words. - **The start date**, if any, is whatever todo.parse-date-phrase reads as a whole: `tomorrow`, `on fri`, `next week`, `2026-10-15`. - **Anything else is null**: words left over (`every week please`), a date first (`tomorrow every week`), `weekly` on its own (a title word to quick-add), an empty box, `every 1000 days`. A form shows its own "try every week" message for null.
## Where the series starts
The anchor is the series' first date, and so the todo's due date from then on (todo.item refuses a due date before the anchor).
1. The date typed in the box, if there is one. 2. Otherwise `due`, the todo's due date. 3. Otherwise quick-add's choice: today; for `every weekday` today if it is Monday to Friday, else next Monday; for `every <weekday>` the next such day strictly after today.
Then, for steps 1 and 2, the start moves forward to the first day the pattern allows: `every monday` on a todo due Wednesday 30 September starts Monday 5 October (quick-add, by contrast, lets a date phrase in the same line win and repeats weekly on Wednesdays; in a Repeat box the weekday is the point), and `every weekday` from a Saturday starts the Monday after. A start already on an allowed day stays: `every friday` due Friday 2 October starts that day.
Month ends and leap days are left to todo.next-occurrence: `every month` from 31 January is anchored on the 31st, so it falls on each month's last day.
## Errors
Only a malformed or impossible `today` or `due` throws, with dates.add-days's message (`"28/09/2026" is not an ISO date`). Text that is not a repeat is null, never an error.
Files
| Path | Bytes |
|---|---|
| README.md | 2,376 |
| impl/python.py | 2,160 |
| impl/rust.rs | 2,807 |
| impl/typescript.ts | 2,368 |
| vectors.json | 3,797 |