Functional Weave
Code in Python

todo.list@1.1.0

README.md

5,427 bytes · view raw

# todo.list

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. |
| `toggleAllMode(todos)` | What `toggleAll` would do now: `complete`, `reopen` or `none`. New in 1.1.0. |

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

## New in 1.1.0: toggleAllMode

A "Complete all" button has to say whether pressing it will complete or
reopen, and working that out in the app repeats `toggleAll`'s rule (and can
drift from it). `toggleAllMode(todos)` answers it: `complete` while any
todo is open, `reopen` once none is, and `none` for an empty list (where
`toggleAll` changes nothing, so a button can be disabled). `toggleAll` now
asks `toggleAllMode` itself, so the label and the action cannot disagree.
Every other function, and every 1.0.0 vector, is unchanged.