todo.item
The to-do item type (Todo, TodoDraft, Recurrence) and its validators, with a message per field.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 51 tests, run in TypeScript, Python and Rust.validateDraft 35 · validateTodo 16
What it does
The shape of one to-do item, shared by every `todo.*` capability, and the two checks that say whether one is acceptable. `Todo` is what is stored; `TodoDraft` is the part a person edits (the add and edit forms, and what todo.parse-quick-add produces); `Recurrence` says how an item repeats.
`validateDraft` is for forms: it returns `valid` and one message per field that needs fixing, keyed by the field's name, so a form can show each message under its input and an API can return them as a validation error. It never throws. `validateTodo` is for stored items (read back from storage, or imported): the same rules plus the ones only a stored item has.
The functions
A group: 2 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.
- validate_draft (draft: TodoDraft) -> TodoValidation
- validate_todo (todo: Todo) -> TodoValidation
The types it declares, generated into your project
Priority = Literal["none", "low", "medium", "high"]
RecurrenceFrequency = Literal["daily", "weekdays", "weekly", "monthly", "yearly"]
@dataclass(frozen=True)
class Recurrence:
"""How a todo repeats. Every occurrence is counted from the anchor, so month ends stay month ends."""
frequency: RecurrenceFrequency
#: every how many days, weeks, months or years, 1 to 999; always 1 for weekdays
interval: int
#: the first due date of the series
anchor: str
@dataclass(frozen=True)
class Todo:
"""One to-do item as it is stored."""
id: str
#: trimmed, one line, 1 to 200 characters
title: str
#: up to 2000 characters
notes: Optional[str]
done: bool
priority: Priority
due: Optional[str]
#: normalised with todo.normalise-tags: lower-case slugs, no duplicates, at most 10
tags: List[str]
#: needs a due date
recurrence: Optional[Recurrence]
#: ISO 8601 UTC timestamp, e.g. 2026-09-28T09:30:00Z
created_at: str
#: set exactly when done
completed_at: Optional[str]
#: manual position, 0 first
order: int
@dataclass(frozen=True)
class TodoDraft:
"""The fields a person edits: what quick-add parses and the add or edit form submits."""
title: str
notes: Optional[str]
priority: Priority
due: Optional[str]
tags: List[str]
recurrence: Optional[Recurrence]
@dataclass(frozen=True)
class TodoValidation:
"""A todo's verdict, shaped for a form's field messages and an API's validation error."""
valid: bool
#: field name to message, in field order; empty when valid
errors: Dict[str, str]
Once installed, your code imports each one from the group's module.
validate_draft 35 tests
def validate_draft(draft: TodoDraft) -> TodoValidation
| draft | TodoDraft | what the add or edit form holds, or what todo.parse-quick-add produced |
| returns | TodoValidation | valid, or a message for each field that needs fixing |
For example
validate_draft(title Buy milk, notes —, priority none, due —, tags , recurrence —)→ valid true, errors … a plain title and nothing else is validvalidate_draft(title Pay rent, notes Standing order failed, priority high, due 2026-10-01, tags home, bills-2026, recurrence …)→ valid true, errors … every field filled in and consistentvalidate_draft(title , notes —, priority none, due —, tags , recurrence —)→ valid false, errors … a blank title (spaces only) needs a title
from fune.todo.item import validate_draft # todo.item@^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 Any, List, Optional, Sequence, Tuple
from .dates_add_days import days_in_month ← from dates.add-days ^1.0.0 · built alongside by fune
from .todo_item_types import TodoDraft, TodoValidation
MAX_TITLE = 200
MAX_NOTES = 2000
MAX_TAGS = 10
MAX_TAG_LENGTH = 30
MAX_INTERVAL = 999
_PRIORITIES = ("none", "low", "medium", "high")
_FREQUENCIES = ("daily", "weekdays", "weekly", "monthly", "yearly")
_SPACE = " \t\n\r\x0b\x0c"
def trim_space(text: str) -> str:
"""Trim ASCII whitespace only, so "trimmed" means the same in every language."""
return text.strip(_SPACE)
def code_point_length(text: str) -> int:
return len(text)
def _digits(text: str, start: int, stop: int) -> bool:
return all("0" <= ch <= "9" for ch in text[start:stop])
def is_iso_date(text: Any) -> bool:
"""A real calendar date written YYYY-MM-DD, years 0001 to 9999. Never raises."""
if not isinstance(text, str) or len(text) != 10 or text[4] != "-" or text[7] != "-":
return False
if not (_digits(text, 0, 4) and _digits(text, 5, 7) and _digits(text, 8, 10)):
return False
year, month, day = int(text[0:4]), int(text[5:7]), int(text[8:10])
return year >= 1 and 1 <= month <= 12 and 1 <= day <= days_in_month(year, month)
def _is_tag_form(tag: str) -> bool:
if tag == "" or tag[0] == "-" or tag[-1] == "-":
return False
for i, ch in enumerate(tag):
ok = ("a" <= ch <= "z") or ("0" <= ch <= "9") or (ch == "-" and tag[i - 1] != "-")
if not ok:
return False
return True
def _title_error(title: str) -> Optional[str]:
trimmed = trim_space(title)
if trimmed == "":
return "Enter a title."
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in trimmed):
return "Keep the title to one line, without control characters."
if len(trimmed) > MAX_TITLE:
return f"Use no more than {MAX_TITLE} characters for the title."
return None
def _tags_error(tags: Sequence[str]) -> Optional[str]:
if len(tags) > MAX_TAGS:
return f"Use no more than {MAX_TAGS} tags."
seen: List[str] = []
for tag in tags:
if not _is_tag_form(tag):
return f'Tag "{tag}" can only use lower-case letters, digits and single hyphens.'
if len(tag) > MAX_TAG_LENGTH:
return f'Tag "{tag}" is longer than {MAX_TAG_LENGTH} characters.'
if tag in seen:
return f'Tag "{tag}" is listed twice.'
seen.append(tag)
return None
def _recurrence_error(draft: Any) -> Optional[str]:
rule = draft.recurrence
if rule is None:
return None
if rule.frequency not in _FREQUENCIES:
return "Choose how it repeats: daily, weekdays, weekly, monthly or yearly."
interval = rule.interval
if isinstance(interval, bool) or not isinstance(interval, int) or interval < 1 or interval > MAX_INTERVAL:
return f"Repeat every 1 to {MAX_INTERVAL} days, weeks, months or years."
if rule.frequency == "weekdays" and interval != 1:
return "Weekday repeats cannot skip weeks; use an interval of 1."
if not is_iso_date(rule.anchor):
return "Enter the repeat start as a real date, YYYY-MM-DD."
if draft.due is None:
return "A repeating todo needs a due date."
if is_iso_date(draft.due) and rule.anchor > draft.due:
return "The repeat cannot start after the due date."
return None
def draft_errors(draft: Any) -> List[Tuple[str, str]]:
"""The edit rules as ordered (field, message) pairs; works on a Todo too."""
errors: List[Tuple[str, str]] = []
title = _title_error(draft.title)
if title is not None:
errors.append(("title", title))
if draft.notes is not None and len(draft.notes) > MAX_NOTES:
errors.append(("notes", f"Use no more than {MAX_NOTES} characters for the notes."))
if draft.priority not in _PRIORITIES:
errors.append(("priority", "Choose a priority: none, low, medium or high."))
if draft.due is not None and not is_iso_date(draft.due):
errors.append(("due", "Enter the due date as a real date, YYYY-MM-DD."))
tags = _tags_error(draft.tags)
if tags is not None:
errors.append(("tags", tags))
recurrence = _recurrence_error(draft)
if recurrence is not None:
errors.append(("recurrence", recurrence))
return errors
def validate_draft(draft: TodoDraft) -> TodoValidation:
"""Check what a person typed into the add or edit form; the title is judged trimmed."""
errors = dict(draft_errors(draft))
return TodoValidation(valid=len(errors) == 0, errors=errors)validate_todo 16 tests
def validate_todo(todo: Todo) -> TodoValidation
| todo | Todo | a stored item, e.g. one read back from storage or an import |
| returns | TodoValidation | the draft rules plus id, timestamps, completion and order |
For example
validate_todo(id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0)→ valid true, errors … a stored open todovalidate_todo(id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at 2026-09-28T18:00:00Z, order 0)→ valid true, errors … a stored done todo with its completion timevalidate_todo(id a-1, title Pay rent, notes via bank, done true, priority high, due 2026-10-01, tags home, recurrence …, created at 2026-09-28T09:30:00.123Z, completed at 2026-10-01T07:00:00.5Z…)→ valid true, errors … a full stored todo with fractional-second timestamps, as JavaScript writes them
from fune.todo.item import validate_todo # todo.item@^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 Any, Dict
from .todo_item_types import Todo, TodoValidation
from .todo_item_validate_draft import draft_errors, is_iso_date, trim_space ← validateDraft, another function of this group · built into the same file, even by a slim install
_FIELD_ORDER = ("id", "title", "notes", "priority", "due", "tags", "recurrence", "createdAt", "completedAt", "order")
def is_utc_timestamp(text: Any) -> bool:
"""YYYY-MM-DDTHH:MM:SS, an optional 1-9 digit fraction, then Z."""
if not isinstance(text, str) or len(text) < 20:
return False
if not is_iso_date(text[0:10]) or text[10] != "T" or text[13] != ":" or text[16] != ":":
return False
def two(at: int, most: int) -> bool:
a, b = text[at], text[at + 1]
return "0" <= a <= "9" and "0" <= b <= "9" and int(a + b) <= most
if not (two(11, 23) and two(14, 59) and two(17, 59)):
return False
rest = text[19:]
if rest.startswith("."):
n = 1
while n < len(rest) and "0" <= rest[n] <= "9":
n += 1
if n == 1 or n > 10:
return False
rest = rest[n:]
return rest == "Z"
def validate_todo(todo: Todo) -> TodoValidation:
"""Check a stored todo: the draft rules plus id, trimmed title, timestamps, completion and order."""
found: Dict[str, str] = dict(draft_errors(todo))
if trim_space(todo.id) == "":
found["id"] = "Every todo needs an id."
if "title" not in found and trim_space(todo.title) != todo.title:
found["title"] = "Remove the spaces around the title."
if not is_utc_timestamp(todo.created_at):
found["createdAt"] = "Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z."
if todo.done and todo.completed_at is None:
found["completedAt"] = "A done todo needs the time it was completed."
elif not todo.done and todo.completed_at is not None:
found["completedAt"] = "An open todo cannot have a completion time."
elif todo.completed_at is not None and not is_utc_timestamp(todo.completed_at):
found["completedAt"] = "Record when it was completed as a UTC timestamp, e.g. 2026-09-28T09:30:00Z."
order = todo.order
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
found["order"] = "Order must be a whole number, 0 or more."
errors = {field: found[field] for field in _FIELD_ORDER if field in found}
return TodoValidation(valid=len(errors) == 0, errors=errors)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, 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.item
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add todo.item --only validateDraft
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./todo.item-1.0.0-python.fune, or fetch it from a terminal with fune pull todo.item@1.0.0:python.
The whole function, every language, is one file too: todo.item-1.0.0.fune, 65,295 bytes, sha256 3b6af46b7431804010e2f024169dc8d1e700829887069428d844cbc5f8d9e295. 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.item.validateDraft
# fune: before todo.item.validateTodo
after — your function gets the result and the arguments, and returns the final result.
# fune: after todo.item.validateDraft
# fune: after todo.item.validateTodo
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.item
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.item --steps.
# fune: step todo.item.<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.
validateDraft 35 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a plain title and nothing else is valid | title Buy milk, notes —, priority none, due —, tags , recurrence — | → | valid true, errors … |
| every field filled in and consistent | title Pay rent, notes Standing order failed, priority high, due 2026-10-01, tags home, bills-2026, recurrence … | → | valid true, errors … |
| a blank title (spaces only) needs a title | title , notes —, priority none, due —, tags , recurrence — | → | valid false, errors … |
| an empty title needs a title | title , notes —, priority none, due —, tags , recurrence — | → | valid false, errors … |
| spaces around the title are fine in a draft: it is judged trimmed | title Buy milk , notes —, priority none, due —, tags , recurrence — | → | valid true, errors … |
| 200 characters is the longest title | title aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | valid true, errors … |
| 201 characters is too long | title aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | valid false, errors … |
| 200 emoji is 200 characters, not 400 UTF-16 units or 800 bytes | title 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀�… | → | valid true, errors … |
| 201 accented letters count as 201, not as 402 bytes | title ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé… | → | valid false, errors … |
| spaces do not count towards the length | title aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | valid true, errors … |
Show the other 25 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a line break inside the title is refused | title Buy milk, notes —, priority none, due —, tags , recurrence — | → | valid false, errors … |
| a trailing line break is trimmed away, not refused | title Buy milk , notes —, priority none, due —, tags , recurrence — | → | valid true, errors … |
| 2000 characters of notes is allowed | title Buy milk, notes nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn… | → | valid true, errors … |
| 2001 characters of notes is too long | title Buy milk, notes nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn… | → | valid false, errors … |
| an unknown priority | title Buy milk, notes —, priority urgent, due —, tags , recurrence — | → | valid false, errors … |
| a due date that never existed | title Buy milk, notes —, priority none, due 2026-02-30, tags , recurrence — | → | valid false, errors … |
| 29 February in a leap year is a real date | title Buy milk, notes —, priority none, due 2028-02-29, tags , recurrence — | → | valid true, errors … |
| a due date with a trailing newline is not a date | title Buy milk, notes —, priority none, due 2026-09-28 , tags , recurrence — | → | valid false, errors … |
| a due date in Arabic-Indic digits is not a date | title Buy milk, notes —, priority none, due ٢٠٢٦-09-28, tags , recurrence — | → | valid false, errors … |
| a tag with a capital letter is not in normal form | title Buy milk, notes —, priority none, due —, tags Home, recurrence — | → | valid false, errors … |
| a tag with a # is not in normal form | title Buy milk, notes —, priority none, due —, tags #home, recurrence — | → | valid false, errors … |
| a double hyphen is not in normal form | title Buy milk, notes —, priority none, due —, tags a--b, recurrence — | → | valid false, errors … |
| the same tag twice | title Buy milk, notes —, priority none, due —, tags home, work, home, recurrence — | → | valid false, errors … |
| ten tags is the most | title Buy milk, notes —, priority none, due —, tags t0, t1, t2, t3, t4, t5, t6, t7, t8, t9, recurrence — | → | valid true, errors … |
| eleven tags is too many | title Buy milk, notes —, priority none, due —, tags t0, t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, recurrence — | → | valid false, errors … |
| a 31-character tag is too long; 30 is fine | title Buy milk, notes —, priority none, due —, tags aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, recurrence — | → | valid false, errors … |
| a repeat needs a due date | title Buy milk, notes —, priority none, due —, tags , recurrence … | → | valid false, errors … |
| weekday repeats cannot skip weeks | title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … | → | valid false, errors … |
| an interval of 0 repeats nothing | title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … | → | valid false, errors … |
| 999 is the largest interval | title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … | → | valid true, errors … |
| an unknown frequency | title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … | → | valid false, errors … |
| a repeat start that is not a date | title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … | → | valid false, errors … |
| a repeat cannot start after the due date | title Buy milk, notes —, priority none, due 2026-09-28, tags , recurrence … | → | valid false, errors … |
| a later occurrence of a series started earlier is fine | title Buy milk, notes —, priority none, due 2026-11-30, tags , recurrence … | → | valid true, errors … |
| several problems are all reported, one message per field | title , notes nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn… | → | valid false, errors … |
validateTodo 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a stored open todo | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 | → | valid true, errors … |
| a stored done todo with its completion time | id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at 2026-09-28T18:00:00Z, order 0 | → | valid true, errors … |
| a full stored todo with fractional-second timestamps, as JavaScript writes them | id a-1, title Pay rent, notes via bank, done true, priority high, due 2026-10-01, tags home, recurrence …, created at 2026-09-28T09:30:00.123Z, completed at 2026-10-01T07:00:00.5Z… | → | valid true, errors … |
| a done todo without a completion time | id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 | → | valid false, errors … |
| an open todo cannot have a completion time | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at 2026-09-28T18:00:00Z, order 0 | → | valid false, errors … |
| a malformed completion time | id t1, title Buy milk, notes —, done true, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at yesterday, order 0 | → | valid false, errors … |
| a stored title must already be trimmed | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 | → | valid false, errors … |
| a blank stored title says enter a title, not remove the spaces | id t1, title , notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 | → | valid false, errors … |
| an empty id | id , title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order 0 | → | valid false, errors … |
| a created time with a space instead of T | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28 09:30:00Z, completed at —, order 0 | → | valid false, errors … |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a created time with an offset instead of Z | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00+01:00, completed at —, order 0 | → | valid false, errors … |
| hour 24 is not a time | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T24:00:00Z, completed at —, order 0 | → | valid false, errors … |
| a created time on 30 February | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-02-30T09:30:00Z, completed at —, order 0 | → | valid false, errors … |
| a fraction of ten digits is too precise | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00.1234567890Z, completed at —, order 0 | → | valid false, errors … |
| a negative order | id t1, title Buy milk, notes —, done false, priority none, due —, tags , recurrence —, created at 2026-09-28T09:30:00Z, completed at —, order -1 | → | valid false, errors … |
| the draft rules apply to a stored todo, and every message comes back | id , title , notes —, done false, priority none, due —, tags home, home, recurrence —, created at , completed at —, order -2 | → | valid false, errors … |
More from the author
## The rules
- **title**: judged after trimming, 1 to 200 characters, one line (no control characters). In a stored todo it must already be trimmed. - **notes**: optional, up to 2000 characters. - **priority**: `none`, `low`, `medium` or `high`. - **due**: optional, a real calendar date `YYYY-MM-DD`. A due date has no time of day: a to-do is due on a day. - **tags**: at most 10, each in the normal form todo.normalise-tags produces (lower-case ASCII letters and digits, runs joined by single hyphens), at most 30 characters, no duplicates. The first problem is reported. - **recurrence**: optional; frequency `daily`, `weekdays`, `weekly`, `monthly` or `yearly`, interval 1 to 999 (weekdays only 1), a real `anchor` date, and it needs a due date no earlier than the anchor. - **id** (stored): not blank. Ids are the caller's: pass one in. - **createdAt**, **completedAt** (stored): UTC timestamps `YYYY-MM-DDTHH:MM:SS[.fraction]Z`, as JavaScript's `toISOString` writes them. Offsets are refused so that timestamps compare as strings. `completedAt` is set exactly when `done` is true. - **order** (stored): the manual position, a whole number 0 or more.
## Decisions
"Characters" are Unicode code points, which all three languages count the same way: 200 emoji is 200 characters, not 400 UTF-16 units.
Trimming removes ASCII whitespace only (space, tab, CR, LF, VT, FF). The three standard libraries trim different sets of Unicode spaces, so a wider trim would store different titles in different services.
The anchor is why month ends survive: a monthly todo anchored on 31 January is due 28 February, then 31 March, because every occurrence is counted from the anchor (todo.next-occurrence). Without it, the 28 February copy would drift to the 28th for ever.
Messages are full sentences written for the person at the form, and the same in every language.
Files
| Path | Bytes |
|---|---|
| README.md | 2,552 |
| impl/python/validate_draft.py | 4,564 |
| impl/python/validate_todo.py | 2,388 |
| impl/rust/validate_draft.rs | 7,775 |
| impl/rust/validate_todo.rs | 4,799 |
| impl/typescript/validate_draft.ts | 5,661 |
| impl/typescript/validate_todo.ts | 2,802 |
| vectors.json | 24,788 |