Functional Weave
Code in TypeScript

todo.list

The to-do list reducer: add, edit, complete, remove, clear and reorder todos, each returning a new list.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 94 tests, run in TypeScript, Python and Rust.addTodo 19 · updateTodo 12 · toggleTodo 18 · removeTodo 11 · clearCompleted 8 · toggleAll 12 · moveTodo 14

What it does

The reducer over a to-do list: every change a to-do app makes to its list, as functions that take the list and return a new one. The input is never changed, and the array keeps its order unless a function says otherwise, so the result can go straight into a state store, an undo stack or storage.

| Function | What it does | | --- | --- | | `addTodo(todos, draft, id, now)` | Appends a new open todo built from the add form. | | `updateTodo(todos, id, draft)` | Replaces a todo's editable fields from the edit form. | | `toggleTodo(todos, id, now, today)` | Completes or reopens one todo. | | `removeTodo(todos, id)` | Deletes one todo. | | `clearCompleted(todos)` | Deletes every done todo. | | `toggleAll(todos, now, today)` | Completes everything open, or reopens everything once all are done. | | `moveTodo(todos, id, toIndex)` | Drag and drop in manual order. |

The functions

A group: 7 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. addTodo (todos: Todo[], draft: TodoDraft, id: string, now: string) -> Todo[]
  2. updateTodo (todos: Todo[], id: string, draft: TodoDraft) -> Todo[]
  3. toggleTodo (todos: Todo[], id: string, now: string, today: date) -> Todo[]
  4. removeTodo (todos: Todo[], id: string) -> Todo[]
  5. clearCompleted (todos: Todo[]) -> Todo[]
  6. toggleAll (todos: Todo[], now: string, today: date) -> Todo[]
  7. moveTodo (todos: Todo[], id: string, toIndex: int) -> Todo[]

Once installed, your code imports each one from the group's module.

addTodo throws on bad input 19 tests

export function addTodo(todos: readonly Todo[], draft: TodoDraft, id: string, now: string): readonly Todo[]
todosTodo[]
draftTodoDraftwhat the add form submitted; trimmed and tags normalised before it is checked
idstringa new id the app generated
nowstringthe current UTC timestamp, e.g. 2026-09-28T09:30:00Z
returnsTodo[]the list with the new todo appended, last in manual order

For example

  • addTodo(, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z) → ×1 the first todo in an empty list has order 0
  • addTodo(todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z) → ×3 appended after the others, one past the highest order
  • addTodo(todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z) → ×3 order is one past the highest, not the count, and it still goes at the end of the array
import { addTodo } from "#fune/todo.list@^1";
impl/typescript/add_todo.ts · 60 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.

import { type Todo, type TodoDraft, validateDraft } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { trimSpace } from "./todo_item_validate_draft.ts";
import { isUtcTimestamp } from "./todo_item_validate_todo.ts";
import { normaliseTags } from "./todo_normalise_tags.ts";  ← from todo.normalise-tags ^1.0.0 · built alongside by fune

/** Refuse a clock reading that would not compare as a string with the stored ones. */
export function requireUtcNow(now: string): void {
  if (!isUtcTimestamp(now)) throw new RangeError("now must be a UTC timestamp like 2026-09-28T09:30:00Z");
}

/**
 * The draft as it will be stored: title and notes trimmed (ASCII only, as
 * todo.item does), blank notes as null, tags in normal form. Then checked with
 * validateDraft; the first field in field order that fails is the error, so
 * the message names what to fix first.
 */
export function checkedDraft(draft: TodoDraft): TodoDraft {
  const notes = draft.notes === null || draft.notes === undefined ? "" : trimSpace(draft.notes);
  const clean: TodoDraft = {
    title: trimSpace(draft.title),
    notes: notes === "" ? null : notes,
    priority: draft.priority,
    due: draft.due ?? null,
    tags: normaliseTags(draft.tags),
    recurrence: draft.recurrence ?? null,
  };
  const verdict = validateDraft(clean);
  const first = Object.entries(verdict.errors)[0];
  if (first !== undefined) throw new RangeError(`invalid todo: ${first[0]}: ${first[1]}`);
  return clean;
}

/**
 * Add a todo from the add form. The id and the time are the app's: a pure
 * function cannot mint either. The new todo is open, created now, last in
 * manual order (one past the highest order in the list), and appended.
 * Caller mistakes (id, clock) are reported before form mistakes (the draft).
 */
export function addTodo(todos: readonly Todo[], draft: TodoDraft, id: string, now: string): readonly Todo[] {
  if (trimSpace(id).length === 0) throw new RangeError("a todo needs an id");
  if (todos.some((t) => t.id === id)) throw new RangeError(`a todo with id "${id}" already exists`);
  requireUtcNow(now);
  const clean = checkedDraft(draft);
  let order = 0;
  for (const t of todos) if (t.order + 1 > order) order = t.order + 1;
  const todo: Todo = {
    id,
    title: clean.title,
    notes: clean.notes,
    done: false,
    priority: clean.priority,
    due: clean.due,
    tags: clean.tags,
    recurrence: clean.recurrence,
    createdAt: now,
    completedAt: null,
    order,
  };
  return [...todos, todo];
}

updateTodo throws on bad input 12 tests

export function updateTodo(todos: readonly Todo[], id: string, draft: TodoDraft): readonly Todo[]
todosTodo[]
idstring
draftTodoDraftthe edit form's fields; id, done, timestamps and order are kept
returnsTodo[]

For example

  • updateTodo(todos ×3, b, title Pay the rent, notes by standing order, priority high, due 2026-10-01, tags home, recurrence —) → ×3 replaces the edited fields of one todo and leaves the others alone
  • updateTodo(todos ×1, b, title Pay the rent, notes —, priority none, due —, tags , recurrence —) → ×1 a done todo stays done with its completion time
  • updateTodo(todos ×1, a, title Walk the dog twice , notes —, priority none, due —, tags Dog Walks, #dog-walks, recurrence —) → ×1 trims the title and normalises tags as addTodo does
import { updateTodo } from "#fune/todo.list@^1";
impl/typescript/update_todo.ts · 32 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.

import { type Todo, type TodoDraft } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { checkedDraft } from "./todo_list_add_todo.ts";  ← addTodo, another function of this group · built into the same file, even by a slim install

/** The position of the todo with this id, or the unknown-id error every list operation shares. */
export function indexOfTodo(todos: readonly Todo[], id: string): number {
  const at = todos.findIndex((t) => t.id === id);
  if (at < 0) throw new RangeError(`no todo with id "${id}"`);
  return at;
}

/**
 * Save the edit form: the draft's fields replace the todo's, normalised and
 * checked exactly as addTodo does. The todo keeps its id, done state,
 * timestamps, order and place in the list.
 */
export function updateTodo(todos: readonly Todo[], id: string, draft: TodoDraft): readonly Todo[] {
  const at = indexOfTodo(todos, id);
  const clean = checkedDraft(draft);
  return todos.map((t, i) =>
    i !== at
      ? t
      : {
          ...t,
          title: clean.title,
          notes: clean.notes,
          priority: clean.priority,
          due: clean.due,
          tags: clean.tags,
          recurrence: clean.recurrence,
        },
  );
}

toggleTodo throws on bad input 18 tests

export function toggleTodo(todos: readonly Todo[], id: string, now: string, today: string): readonly Todo[]
todosTodo[]
idstring
nowstringwhen it was completed
todaydatethe user's local date, for rolling a repeating todo forward
returnsTodo[]done or reopened; a repeating todo stays open with its next due date

For example

  • toggleTodo(todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28) → ×1 completing an open todo sets done and the completion time
  • toggleTodo(todos ×1, b, 2026-09-28T09:30:00Z, 2026-09-28) → ×1 toggling a done todo reopens it and clears the completion time
  • toggleTodo(todos ×3, b, 2026-09-28T09:30:00Z, 2026-09-28) → ×3 only the named todo changes
import { toggleTodo } from "#fune/todo.list@^1";
impl/typescript/toggle_todo.ts · 36 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.

import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { isIsoDate } from "./todo_item_validate_draft.ts";
import { nextOccurrence } from "./todo_next_occurrence.ts";  ← from todo.next-occurrence ^1.0.0 · built alongside by fune
import { requireUtcNow } from "./todo_list_add_todo.ts";  ← addTodo, another function of this group · built into the same file, even by a slim install
import { indexOfTodo } from "./todo_list_update_todo.ts";  ← updateTodo, another function of this group · built into the same file, even by a slim install

/** Refuse a today that is not a real YYYY-MM-DD date, whether or not a repeat needs it. */
export function requireToday(today: string): void {
  if (!isIsoDate(today)) throw new RangeError("today must be a date like 2026-09-28");
}

/**
 * Complete one open todo. A one-off is done at `now`. A repeating one stays
 * open and rolls forward to its next occurrence after both its due date and
 * today (todo.next-occurrence), the way Todoist completes a recurring task:
 * no second id to mint, and no duplicate series if a copy were reopened.
 */
export function completeTodo(todo: Todo, now: string, today: string): Todo {
  const rule = todo.recurrence ?? null;
  if (rule === null) return { ...todo, done: true, completedAt: now };
  if (todo.due === null || todo.due === undefined) {
    throw new RangeError(`repeating todo "${todo.id}" has no due date`);
  }
  return { ...todo, done: false, completedAt: null, due: nextOccurrence(rule, todo.due, today) };
}

/** Tick or untick one todo. Reopening clears the completion time. */
export function toggleTodo(todos: readonly Todo[], id: string, now: string, today: string): readonly Todo[] {
  const at = indexOfTodo(todos, id);
  requireUtcNow(now);
  requireToday(today);
  return todos.map((t, i) => {
    if (i !== at) return t;
    return t.done ? { ...t, done: false, completedAt: null } : completeTodo(t, now, today);
  });
}

removeTodo throws on bad input 11 tests

export function removeTodo(todos: readonly Todo[], id: string): readonly Todo[]
todosTodo[]
idstring
returnsTodo[]

For example

  • removeTodo(todos ×3, b) → ×2 removes the middle todo; the others keep their order values
  • removeTodo(todos ×3, a) → ×2 removes the first
  • removeTodo(todos ×3, c) → ×2 removes the last
import { removeTodo } from "#fune/todo.list@^1";
impl/typescript/remove_todo.ts · 11 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.

import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { indexOfTodo } from "./todo_list_update_todo.ts";  ← updateTodo, another function of this group · built into the same file, even by a slim install

/**
 * Delete one todo. The others keep their order values: a gap is harmless,
 * because manual order sorts by order rather than counting positions.
 */
export function removeTodo(todos: readonly Todo[], id: string): readonly Todo[] {
  const at = indexOfTodo(todos, id);
  return todos.filter((_, i) => i !== at);
}

clearCompleted 8 tests

export function clearCompleted(todos: readonly Todo[]): readonly Todo[]
todosTodo[]
returnsTodo[]the open todos only

For example

  • clearCompleted() → an empty list stays empty
  • clearCompleted(todos ×3) → ×3 nothing done: the list is unchanged
  • clearCompleted(todos ×3) → everything done: nothing left
import { clearCompleted } from "#fune/todo.list@^1";
impl/typescript/clear_completed.ts · 6 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.

import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune

/** The "Clear completed" button: every done todo removed, the rest untouched and in order. */
export function clearCompleted(todos: readonly Todo[]): readonly Todo[] {
  return todos.filter((t) => !t.done);
}

toggleAll throws on bad input 12 tests

export function toggleAll(todos: readonly Todo[], now: string, today: string): readonly Todo[]
todosTodo[]
nowstring
todaydate
returnsTodo[]completes every open todo, or reopens all when none is open

For example

  • toggleAll(, 2026-09-28T09:30:00Z, 2026-09-28) → an empty list stays empty
  • toggleAll(todos ×2, 2026-09-28T09:30:00Z, 2026-09-28) → ×2 all open: all completed now
  • toggleAll(todos ×3, 2026-09-28T09:30:00Z, 2026-09-28) → ×3 some open: the open ones are completed, the done one keeps its time
import { toggleAll } from "#fune/todo.list@^1";
impl/typescript/toggle_all.ts · 17 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.

import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { requireUtcNow } from "./todo_list_add_todo.ts";  ← addTodo, another function of this group · built into the same file, even by a slim install
import { completeTodo, requireToday } from "./todo_list_toggle_todo.ts";  ← toggleTodo, another function of this group · built into the same file, even by a slim install

/**
 * The "mark all" checkbox. While anything is open it completes every open
 * todo exactly as toggleTodo would (repeating ones roll forward and stay
 * open); once everything is done it reopens everything.
 */
export function toggleAll(todos: readonly Todo[], now: string, today: string): readonly Todo[] {
  requireUtcNow(now);
  requireToday(today);
  if (todos.some((t) => !t.done)) {
    return todos.map((t) => (t.done ? t : completeTodo(t, now, today)));
  }
  return todos.map((t) => ({ ...t, done: false, completedAt: null }));
}

moveTodo throws on bad input 14 tests

export function moveTodo(todos: readonly Todo[], id: string, toIndex: number): readonly Todo[]
todosTodo[]
idstring
toIndexintwhere it ends up in manual order, 0 first
returnsTodo[]in manual order, with order renumbered 0 to n-1

For example

  • moveTodo(todos ×3, a, 2) → ×3 move the first to the end
  • moveTodo(todos ×3, c, 0) → ×3 move the last to the top
  • moveTodo(todos ×3, b, 1) → ×3 moving to where it already is renumbers the orders
import { moveTodo } from "#fune/todo.list@^1";
impl/typescript/move_todo.ts · 20 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.

import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune
import { indexOfTodo } from "./todo_list_update_todo.ts";  ← updateTodo, another function of this group · built into the same file, even by a slim install

/**
 * Drag and drop. The list is put in manual order (order ascending, ties by
 * position in the array, which need not already be sorted), the todo is
 * taken out and put back so it ends up at toIndex, and every order is
 * renumbered 0 to n-1. The result is returned in that manual order.
 */
export function moveTodo(todos: readonly Todo[], id: string, toIndex: number): readonly Todo[] {
  const from = indexOfTodo(todos, id);
  const last = todos.length - 1;
  if (!Number.isInteger(toIndex) || toIndex < 0 || toIndex > last) {
    throw new RangeError(`toIndex must be between 0 and ${last}, received ${toIndex}`);
  }
  const positions = todos.map((_, i) => i).sort((a, b) => todos[a].order - todos[b].order || a - b);
  const rest = positions.filter((i) => i !== from);
  rest.splice(toIndex, 0, from);
  return rest.map((i, order) => ({ ...todos[i], order }));
}

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.list

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add todo.list --only addTodo
Download for TypeScript todo.list-1.0.0-typescript.fune · 95,258 bytes sha256 eb423b6f9393fc4dea63c4aa1eac6350975da05ac1ea8a52f68e1c432363377b

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

The whole function, every language, is one file too: todo.list-1.0.0.fune, 112,220 bytes, sha256 0605cc9f5a249dd3b2fe88ecf7b795d4fba56c213a196f63fe789a337c56247d. 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.list.addTodo
// fune: before todo.list.updateTodo
// fune: before todo.list.toggleTodo
// fune: before todo.list.removeTodo
// fune: before todo.list.clearCompleted
// fune: before todo.list.toggleAll
// fune: before todo.list.moveTodo

after — your function gets the result and the arguments, and returns the final result.

// fune: after todo.list.addTodo
// fune: after todo.list.updateTodo
// fune: after todo.list.toggleTodo
// fune: after todo.list.removeTodo
// fune: after todo.list.clearCompleted
// fune: after todo.list.toggleAll
// fune: after todo.list.moveTodo

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 todo.item in todo.list
// fune: replace todo.next-occurrence in todo.list
// fune: replace todo.normalise-tags in todo.list

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.list --steps.

// fune: step todo.list.<fn> 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.

addTodo 19 tests

CaseArgumentsExpected
the first todo in an empty list has order 0 , title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×1
appended after the others, one past the highest order todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×3
order is one past the highest, not the count, and it still goes at the end of the array todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×3
title and notes are trimmed of ASCII whitespace , title Buy milk , notes call first , priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×1
blank notes are stored as null , title Buy milk, notes , priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×1
tags are normalised: # dropped, lower-case slugs, duplicates removed , title Buy milk, notes —, priority none, due —, tags #Home, home office , HOME, recurrence —, n, 2026-09-28T09:30:00Z → ×1
priority, due date and recurrence are kept as given , title Pay rent, notes —, priority high, due 2026-10-01, tags , recurrence …, n, 2026-09-28T09:30:00Z → ×1
a done todo already in the list is left alone todos ×1, title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → ×2
eleven tags that normalise to ten distinct ones are fine , title Buy milk, notes —, priority none, due —, tags #a, A, b, c, d, e, f, g, h, i, j, recurrence —, n, 2026-09-28T09:30:00Z → ×1
eleven distinct tags are too many , title Buy milk, notes —, priority none, due —, tags a, b, c, d, e, f, g, h, i, j, k, recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: tags: Use no more than 10 tags.
Show the other 9 tests
CaseArgumentsExpected
a blank title is refused , title , notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: title: Enter a title.
with a blank title and an impossible due date, the title is reported first , title , notes —, priority none, due 2026-02-30, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: title: Enter a title.
an impossible due date , title Buy milk, notes —, priority none, due 2026-02-30, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: due: Enter the due date as a real date, YYYY-MM-DD.
an unknown priority , title Buy milk, notes —, priority urgent, due —, tags , recurrence —, n, 2026-09-28T09:30:00Z → error: invalid todo: priority: Choose a priority: none, low, medium or high.
a repeat without a due date , title Buy milk, notes —, priority none, due —, tags , recurrence …, n, 2026-09-28T09:30:00Z → error: invalid todo: recurrence: A repeating todo needs a due date.
a blank id , title Buy milk, notes —, priority none, due —, tags , recurrence —, , 2026-09-28T09:30:00Z → error: a todo needs an id
an id already in the list todos ×2, title Buy milk, notes —, priority none, due —, tags , recurrence —, b, 2026-09-28T09:30:00Z → error: a todo with id "b" already exists
now with an offset is not a UTC timestamp , title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28T10:30:00+01:00 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
now as a bare date is not a timestamp , title Buy milk, notes —, priority none, due —, tags , recurrence —, n, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z

updateTodo 12 tests

CaseArgumentsExpected
replaces the edited fields of one todo and leaves the others alone todos ×3, b, title Pay the rent, notes by standing order, priority high, due 2026-10-01, tags home, recurrence — → ×3
a done todo stays done with its completion time todos ×1, b, title Pay the rent, notes —, priority none, due —, tags , recurrence — → ×1
trims the title and normalises tags as addTodo does todos ×1, a, title Walk the dog twice , notes —, priority none, due —, tags Dog Walks, #dog-walks, recurrence — → ×1
an empty notes clears the notes to null todos ×1, a, title Walk the dog, notes , priority none, due —, tags , recurrence — → ×1
adds a repeat todos ×1, a, title Walk the dog, notes —, priority none, due 2026-09-28, tags , recurrence … → ×1
takes the due date and the repeat away todos ×1, a, title Walk the dog, notes —, priority none, due —, tags , recurrence — → ×1
keeps the todo's place in the array and its order, even when they differ todos ×2, y, title Why, notes —, priority none, due —, tags , recurrence — → ×2
keeps id and createdAt of a one-item list todos ×1, only, title New, notes —, priority low, due —, tags , recurrence — → ×1
an unknown id todos ×2, zz, title Buy milk, notes —, priority none, due —, tags , recurrence — → error: no todo with id "zz"
an unknown id in an empty list , a, title Buy milk, notes —, priority none, due —, tags , recurrence — → error: no todo with id "a"
Show the other 2 tests
CaseArgumentsExpected
a blank title todos ×1, a, title , notes —, priority none, due —, tags , recurrence — → error: invalid todo: title: Enter a title.
a repeat that starts after the due date todos ×1, a, title Walk, notes —, priority none, due 2026-09-28, tags , recurrence … → error: invalid todo: recurrence: The repeat cannot start after the due date.

toggleTodo 18 tests

CaseArgumentsExpected
completing an open todo sets done and the completion time todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
toggling a done todo reopens it and clears the completion time todos ×1, b, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
only the named todo changes todos ×3, b, 2026-09-28T09:30:00Z, 2026-09-28 → ×3
a daily todo done on its day stays open and is due tomorrow todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
monthly anchored on the 31st: January's rolls to 28 February todos ×1, r, 2026-01-31T10:00:00Z, 2026-01-31 → ×1
monthly anchored on the 31st: February's rolls to 31 March, not the 28th todos ×1, r, 2026-02-28T10:00:00Z, 2026-02-28 → ×1
an overdue daily todo skips the missed days: due tomorrow, not the 21st todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
an overdue weekly todo done on its weekday is due next week todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
a weekdays todo done on Friday is due Monday todos ×1, r, 2026-10-02T17:00:00Z, 2026-10-02 → ×1
a monthly todo done early moves past its due date, not back onto it todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
Show the other 8 tests
CaseArgumentsExpected
a yearly todo anchored on 29 February, done late, is due 28 February next year todos ×1, r, 2026-03-01T08:00:00Z, 2026-03-01 → ×1
a done repeating todo is simply reopened, due date unchanged todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
an unknown id todos ×1, zz, 2026-09-28T09:30:00Z, 2026-09-28 → error: no todo with id "zz"
now not in UTC todos ×1, a, 2026-09-28T09:30:00, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
reopening still needs a valid now todos ×1, b, yesterday, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
today that never existed todos ×1, a, 2026-09-28T09:30:00Z, 2026-02-30 → error: today must be a date like 2026-09-28
today with a trailing newline todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28 → error: today must be a date like 2026-09-28
a stored repeating todo without a due date cannot roll forward todos ×1, r, 2026-09-28T09:30:00Z, 2026-09-28 → error: repeating todo "r" has no due date

removeTodo 11 tests

CaseArgumentsExpected
removes the middle todo; the others keep their order values todos ×3, b → ×2
removes the first todos ×3, a → ×2
removes the last todos ×3, c → ×2
removing the only todo leaves an empty list todos ×1, a →
removes a done todo todos ×2, b → ×1
gaps in order are left as they are todos ×3, y → ×2
ids match exactly: removing a leaves A todos ×2, a → ×1
works when the array is not in order sequence todos ×3, x → ×2
an unknown id todos ×2, zz → error: no todo with id "zz"
an unknown id in an empty list , a → error: no todo with id "a"
Show the other 1 test
CaseArgumentsExpected
an id with a space around it is a different id todos ×1, a → error: no todo with id " a"

clearCompleted 8 tests

CaseArgumentsExpected
an empty list stays empty →
nothing done: the list is unchanged todos ×3 → ×3
everything done: nothing left todos ×3 →
keeps the open ones in their order todos ×3 → ×2
done at both ends todos ×3 → ×1
a single done todo todos ×1 →
order values are not renumbered todos ×3 → ×2
an open repeating todo stays todos ×2 → ×1

toggleAll 12 tests

CaseArgumentsExpected
an empty list stays empty , 2026-09-28T09:30:00Z, 2026-09-28 →
all open: all completed now todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
some open: the open ones are completed, the done one keeps its time todos ×3, 2026-09-28T09:30:00Z, 2026-09-28 → ×3
all done: all reopened todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
an open repeating todo rolls forward and stays open while the rest are completed todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
all done, including a repeating one: all reopened todos ×2, 2026-09-28T09:30:00Z, 2026-09-28 → ×2
only repeating todos: they roll forward and are still open afterwards todos ×1, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
a single open todo todos ×1, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
an overdue monthly todo anchored on the 31st rolls to 30 September todos ×1, 2026-09-28T09:30:00Z, 2026-09-28 → ×1
now with an offset todos ×1, 2026-09-28T09:30:00+00:00, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
Show the other 2 tests
CaseArgumentsExpected
a bad now is refused even for an empty list , now, 2026-09-28 → error: now must be a UTC timestamp like 2026-09-28T09:30:00Z
today in Arabic-Indic digits is not a date todos ×1, 2026-09-28T09:30:00Z, ٢٠٢٦-09-28 → error: today must be a date like 2026-09-28

moveTodo 14 tests

CaseArgumentsExpected
move the first to the end todos ×3, a, 2 → ×3
move the last to the top todos ×3, c, 0 → ×3
moving to where it already is renumbers the orders todos ×3, b, 1 → ×3
gaps in order: toIndex counts positions, not order values todos ×3, a, 1 → ×3
array not in order sequence: positions are in manual order, not array order todos ×3, a, 1 → ×3
equal orders are tied by array position todos ×3, c, 0 → ×3
a one-item list: order becomes 0 todos ×1, a, 0 → ×1
move the last of four into the middle todos ×4, d, 1 → ×4
a done todo moves like any other todos ×2, a, 1 → ×2
toIndex past the end todos ×3, a, 3 → error: toIndex must be between 0 and 2, received 3
Show the other 4 tests
CaseArgumentsExpected
a negative toIndex todos ×3, a, -1 → error: toIndex must be between 0 and 2, received -1
a fractional toIndex todos ×3, a, 1.5 → error: toIndex must be between 0 and 2, received 1.5
an unknown id todos ×1, zz, 0 → error: no todo with id "zz"
an unknown id in an empty list , a, 0 → error: no todo with id "a"

More from the author

## Adding and editing

`addTodo` and `updateTodo` clean the draft before they check it: the title and notes are trimmed of ASCII whitespace (todo.item's rule), notes that are blank become `null`, and tags go through todo.normalise-tags, so `#Home`, `home` and ` HOME ` are one tag. Priority, due date and recurrence are stored as given. The cleaned draft is then checked with todo.item's `validateDraft`, and the first failing field, in field order, is the error: `invalid todo: title: Enter a title.` Eleven tags that normalise to ten are fine; eleven distinct ones are not.

A new todo is open, `createdAt` is `now`, `completedAt` is null, and its `order` is one past the highest order in the list (0 in an empty list), so it is last in manual order even when the orders have gaps. It is appended to the end of the array.

`updateTodo` keeps the id, done state, both timestamps, order and array position.

## Completing

A one-off todo becomes done with `completedAt` = `now`; toggling a done todo reopens it and clears `completedAt`.

A repeating todo (recurrence not null) is not marked done: it stays open and its due date moves to its next occurrence, computed by todo.next-occurrence from the anchor, strictly after both the due date and `today`. A monthly todo anchored on 31 January goes 28 February, then 31 March; a daily one that is a week overdue is due tomorrow, not yesterday. This is Todoist's model, and it is chosen over "mark this one done and create the next copy" because:

- a pure function cannot mint the second id the copy would need; - reopening a completed copy would leave two live copies of the series; - `toggleAll` stays well defined: completing everything never grows the list.

A done repeating todo (from an import, say) is simply reopened.

`toggleAll` completes every open todo exactly as `toggleTodo` does while anything is open, and reopens everything once nothing is. So with open repeating todos in the list, pressing it again rolls them forward again rather than reopening the rest: they are still open.

## Moving

`moveTodo` puts the list in manual order (order ascending, ties broken by array position; the array need not already be sorted), takes the todo out, puts it back so that it ends up at `toIndex`, and renumbers every order 0 to n-1. The result is returned in that manual order. `removeTodo` and `clearCompleted` leave the remaining orders as they are: gaps are harmless, because manual order sorts by order rather than counting positions.

## Arguments, not globals

Ids, `now` and `today` are arguments. The app generates ids and reads the clock; the functions stay pure and testable. `now` must be a UTC timestamp like `2026-09-28T09:30:00Z` (todo.item's `isUtcTimestamp`), so that stored timestamps compare as strings; `today` must be a real `YYYY-MM-DD` date and is checked even when no repeating todo needs it, so a bad clock is caught on the first toggle rather than the first repeat.

## Errors

| Message | When | | --- | --- | | `a todo needs an id` | `addTodo` with a blank id | | `a todo with id "<id>" already exists` | `addTodo` with an id in the list | | `no todo with id "<id>"` | any other function given an id not in the list (ids match exactly) | | `now must be a UTC timestamp like 2026-09-28T09:30:00Z` | `addTodo`, `toggleTodo`, `toggleAll` | | `today must be a date like 2026-09-28` | `toggleTodo`, `toggleAll` | | `invalid todo: <field>: <message>` | `addTodo`, `updateTodo` with a draft that fails `validateDraft` | | `repeating todo "<id>" has no due date` | completing a stored repeating todo that has no due date (an invalid stored todo) | | `toIndex must be between 0 and <n-1>, received <x>` | `moveTodo` |

Arguments are checked in this order: the id, then the clock (`now`, `today`), then the draft, so a caller's mistake is reported before a form mistake. `toggleAll` checks `now` and `today` even for an empty list.

Files

PathBytes
README.md4,786
impl/python/add_todo.py2,140
impl/python/clear_completed.py235
impl/python/move_todo.py804
impl/python/remove_todo.py349
impl/python/toggle_all.py611
impl/python/toggle_todo.py1,387
impl/python/update_todo.py931
impl/rust/add_todo.rs2,628
impl/rust/clear_completed.rs418
impl/rust/move_todo.rs1,434
impl/rust/remove_todo.rs609
impl/rust/toggle_all.rs1,095
impl/rust/toggle_todo.rs2,002
impl/rust/update_todo.rs1,264
impl/typescript/add_todo.ts2,422
impl/typescript/clear_completed.ts255
impl/typescript/move_todo.ts977
impl/typescript/remove_todo.ts421
impl/typescript/toggle_all.ts717
impl/typescript/toggle_todo.ts1,666
impl/typescript/update_todo.ts1,079
vectors.json65,249