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.
- add_todo (todos: Todo[], draft: TodoDraft, id: string, now: string) -> Todo[]
- update_todo (todos: Todo[], id: string, draft: TodoDraft) -> Todo[]
- toggle_todo (todos: Todo[], id: string, now: string, today: date) -> Todo[]
- remove_todo (todos: Todo[], id: string) -> Todo[]
- clear_completed (todos: Todo[]) -> Todo[]
- toggle_all (todos: Todo[], now: string, today: date) -> Todo[]
- move_todo (todos: Todo[], id: string, toIndex: int) -> Todo[]
Once installed, your code imports each one from the group's module.
add_todo throws on bad input 19 tests
def add_todo(todos: Sequence[Todo], draft: TodoDraft, id: str, now: str) -> List[Todo]
| todos | Todo[] | |
| draft | TodoDraft | what the add form submitted; trimmed and tags normalised before it is checked |
| id | string | a new id the app generated |
| now | string | the current UTC timestamp, e.g. 2026-09-28T09:30:00Z |
| returns | Todo[] | the list with the new todo appended, last in manual order |
For example
add_todo(, 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 0add_todo(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 orderadd_todo(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
from fune.todo.list import add_todo # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Sequence
from .todo_item import Todo, TodoDraft, validate_draft ← from todo.item ^1.0.0 · built alongside by fune
from .todo_item_validate_draft import trim_space
from .todo_item_validate_todo import is_utc_timestamp
from .todo_normalise_tags import normalise_tags ← from todo.normalise-tags ^1.0.0 · built alongside by fune
def require_utc_now(now: str) -> None:
"""Refuse a clock reading that would not compare as a string with the stored ones."""
if not is_utc_timestamp(now):
raise ValueError("now must be a UTC timestamp like 2026-09-28T09:30:00Z")
def checked_draft(draft: TodoDraft) -> TodoDraft:
"""The draft as it will be stored (trimmed, blank notes None, tags normalised), checked.
The first field in field order that fails validate_draft is the error.
"""
notes = "" if draft.notes is None else trim_space(draft.notes)
clean = TodoDraft(
title=trim_space(draft.title),
notes=None if notes == "" else notes,
priority=draft.priority,
due=draft.due,
tags=list(normalise_tags(draft.tags)),
recurrence=draft.recurrence,
)
verdict = validate_draft(clean)
for field, message in verdict.errors.items():
raise ValueError(f"invalid todo: {field}: {message}")
return clean
def add_todo(todos: Sequence[Todo], draft: TodoDraft, id: str, now: str) -> List[Todo]:
"""Add a todo from the add form: open, created now, last in manual order, appended.
Caller mistakes (id, clock) are reported before form mistakes (the draft).
"""
if trim_space(id) == "":
raise ValueError("a todo needs an id")
if any(t.id == id for t in todos):
raise ValueError(f'a todo with id "{id}" already exists')
require_utc_now(now)
clean = checked_draft(draft)
order = 0
for t in todos:
if t.order + 1 > order:
order = t.order + 1
todo = Todo(
id=id,
title=clean.title,
notes=clean.notes,
done=False,
priority=clean.priority,
due=clean.due,
tags=clean.tags,
recurrence=clean.recurrence,
created_at=now,
completed_at=None,
order=order,
)
return list(todos) + [todo]update_todo throws on bad input 12 tests
def update_todo(todos: Sequence[Todo], id: str, draft: TodoDraft) -> List[Todo]
| todos | Todo[] | |
| id | string | |
| draft | TodoDraft | the edit form's fields; id, done, timestamps and order are kept |
| returns | Todo[] |
For example
update_todo(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 aloneupdate_todo(todos ×1, b, title Pay the rent, notes —, priority none, due —, tags , recurrence —)→ ×1 a done todo stays done with its completion timeupdate_todo(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
from fune.todo.list import update_todo # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from dataclasses import replace
from typing import List, Sequence
from .todo_item import Todo, TodoDraft ← from todo.item ^1.0.0 · built alongside by fune
from .todo_list_add_todo import checked_draft ← addTodo, another function of this group · built into the same file, even by a slim install
def index_of_todo(todos: Sequence[Todo], id: str) -> int:
"""The position of the todo with this id, or the unknown-id error every list operation shares."""
for i, t in enumerate(todos):
if t.id == id:
return i
raise ValueError(f'no todo with id "{id}"')
def update_todo(todos: Sequence[Todo], id: str, draft: TodoDraft) -> List[Todo]:
"""Save the edit form; id, done state, timestamps, order and place are kept."""
at = index_of_todo(todos, id)
clean = checked_draft(draft)
out = list(todos)
out[at] = replace(
todos[at],
title=clean.title,
notes=clean.notes,
priority=clean.priority,
due=clean.due,
tags=clean.tags,
recurrence=clean.recurrence,
)
return outtoggle_todo throws on bad input 18 tests
def toggle_todo(todos: Sequence[Todo], id: str, now: str, today: str) -> List[Todo]
| todos | Todo[] | |
| id | string | |
| now | string | when it was completed |
| today | date | the user's local date, for rolling a repeating todo forward |
| returns | Todo[] | done or reopened; a repeating todo stays open with its next due date |
For example
toggle_todo(todos ×1, a, 2026-09-28T09:30:00Z, 2026-09-28)→ ×1 completing an open todo sets done and the completion timetoggle_todo(todos ×1, b, 2026-09-28T09:30:00Z, 2026-09-28)→ ×1 toggling a done todo reopens it and clears the completion timetoggle_todo(todos ×3, b, 2026-09-28T09:30:00Z, 2026-09-28)→ ×3 only the named todo changes
from fune.todo.list import toggle_todo # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from dataclasses import replace
from typing import List, Sequence
from .todo_item import Todo ← from todo.item ^1.0.0 · built alongside by fune
from .todo_item_validate_draft import is_iso_date
from .todo_list_add_todo import require_utc_now ← addTodo, another function of this group · built into the same file, even by a slim install
from .todo_list_update_todo import index_of_todo ← updateTodo, another function of this group · built into the same file, even by a slim install
from .todo_next_occurrence import next_occurrence ← from todo.next-occurrence ^1.0.0 · built alongside by fune
def require_today(today: str) -> None:
"""Refuse a today that is not a real YYYY-MM-DD date, whether or not a repeat needs it."""
if not is_iso_date(today):
raise ValueError("today must be a date like 2026-09-28")
def complete_todo(todo: Todo, now: str, today: str) -> Todo:
"""Complete one open todo; a repeating one stays open and rolls forward to its next occurrence."""
if todo.recurrence is None:
return replace(todo, done=True, completed_at=now)
if todo.due is None:
raise ValueError(f'repeating todo "{todo.id}" has no due date')
return replace(todo, done=False, completed_at=None, due=next_occurrence(todo.recurrence, todo.due, today))
def toggle_todo(todos: Sequence[Todo], id: str, now: str, today: str) -> List[Todo]:
"""Tick or untick one todo. Reopening clears the completion time."""
at = index_of_todo(todos, id)
require_utc_now(now)
require_today(today)
out = list(todos)
t = todos[at]
out[at] = replace(t, done=False, completed_at=None) if t.done else complete_todo(t, now, today)
return outremove_todo throws on bad input 11 tests
def remove_todo(todos: Sequence[Todo], id: str) -> List[Todo]
| todos | Todo[] | |
| id | string | |
| returns | Todo[] |
For example
remove_todo(todos ×3, b)→ ×2 removes the middle todo; the others keep their order valuesremove_todo(todos ×3, a)→ ×2 removes the firstremove_todo(todos ×3, c)→ ×2 removes the last
from fune.todo.list import remove_todo # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Sequence
from .todo_item import Todo ← from todo.item ^1.0.0 · built alongside by fune
from .todo_list_update_todo import index_of_todo ← updateTodo, another function of this group · built into the same file, even by a slim install
def remove_todo(todos: Sequence[Todo], id: str) -> List[Todo]:
"""Delete one todo; the others keep their order values (gaps are harmless)."""
at = index_of_todo(todos, id)
return [t for i, t in enumerate(todos) if i != at]clear_completed 8 tests
def clear_completed(todos: Sequence[Todo]) -> List[Todo]
| todos | Todo[] | |
| returns | Todo[] | the open todos only |
For example
clear_completed()→ an empty list stays emptyclear_completed(todos ×3)→ ×3 nothing done: the list is unchangedclear_completed(todos ×3)→ everything done: nothing left
from fune.todo.list import clear_completed # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List, Sequence
from .todo_item import Todo ← from todo.item ^1.0.0 · built alongside by fune
def clear_completed(todos: Sequence[Todo]) -> List[Todo]:
"""Every done todo removed, the rest untouched and in order."""
return [t for t in todos if not t.done]toggle_all throws on bad input 12 tests
def toggle_all(todos: Sequence[Todo], now: str, today: str) -> List[Todo]
| todos | Todo[] | |
| now | string | |
| today | date | |
| returns | Todo[] | completes every open todo, or reopens all when none is open |
For example
toggle_all(, 2026-09-28T09:30:00Z, 2026-09-28)→ an empty list stays emptytoggle_all(todos ×2, 2026-09-28T09:30:00Z, 2026-09-28)→ ×2 all open: all completed nowtoggle_all(todos ×3, 2026-09-28T09:30:00Z, 2026-09-28)→ ×3 some open: the open ones are completed, the done one keeps its time
from fune.todo.list import toggle_all # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from dataclasses import replace
from typing import List, Sequence
from .todo_item import Todo ← from todo.item ^1.0.0 · built alongside by fune
from .todo_list_add_todo import require_utc_now ← addTodo, another function of this group · built into the same file, even by a slim install
from .todo_list_toggle_todo import complete_todo, require_today ← toggleTodo, another function of this group · built into the same file, even by a slim install
def toggle_all(todos: Sequence[Todo], now: str, today: str) -> List[Todo]:
"""Complete every open todo as toggle_todo would, or reopen all once none is open."""
require_utc_now(now)
require_today(today)
if any(not t.done for t in todos):
return [t if t.done else complete_todo(t, now, today) for t in todos]
return [replace(t, done=False, completed_at=None) for t in todos]move_todo throws on bad input 14 tests
def move_todo(todos: Sequence[Todo], id: str, to_index: int) -> List[Todo]
| todos | Todo[] | |
| id | string | |
| to_index | int | where it ends up in manual order, 0 first |
| returns | Todo[] | in manual order, with order renumbered 0 to n-1 |
For example
move_todo(todos ×3, a, 2)→ ×3 move the first to the endmove_todo(todos ×3, c, 0)→ ×3 move the last to the topmove_todo(todos ×3, b, 1)→ ×3 moving to where it already is renumbers the orders
from fune.todo.list import move_todo # todo.list@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from dataclasses import replace
from typing import List, Sequence
from .todo_item import Todo ← from todo.item ^1.0.0 · built alongside by fune
from .todo_list_update_todo import index_of_todo ← updateTodo, another function of this group · built into the same file, even by a slim install
def move_todo(todos: Sequence[Todo], id: str, to_index: int) -> List[Todo]:
"""Move a todo to to_index in manual order (order, then array position) and renumber 0..n-1."""
source = index_of_todo(todos, id)
last = len(todos) - 1
if isinstance(to_index, bool) or not isinstance(to_index, int) or to_index < 0 or to_index > last:
raise ValueError(f"toIndex must be between 0 and {last}, received {to_index}")
positions = sorted(range(len(todos)), key=lambda i: (todos[i].order, i))
rest = [i for i in positions if i != source]
rest.insert(to_index, source)
return [replace(todos[i], order=n) for n, i in enumerate(rest)]Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 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.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
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./todo.list-1.0.0-python.fune, or fetch it from a terminal with fune pull todo.list@1.0.0:python.
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| an id with a space around it is a different id | todos ×1, a | → | error: no todo with id " a" |
clearCompleted 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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.