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.
- validateDraft (draft: TodoDraft) -> TodoValidation
- validateTodo (todo: Todo) -> TodoValidation
The types it declares, generated into your project
export type Priority = "none" | "low" | "medium" | "high";
export type RecurrenceFrequency = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
/** How a todo repeats. Every occurrence is counted from the anchor, so month ends stay month ends. */
export interface Recurrence {
readonly frequency: RecurrenceFrequency;
/** every how many days, weeks, months or years, 1 to 999; always 1 for weekdays */
readonly interval: number;
/** the first due date of the series */
readonly anchor: string;
}
/** One to-do item as it is stored. */
export interface Todo {
readonly id: string;
/** trimmed, one line, 1 to 200 characters */
readonly title: string;
/** up to 2000 characters */
readonly notes: string | null;
readonly done: boolean;
readonly priority: Priority;
readonly due: string | null;
/** normalised with todo.normalise-tags: lower-case slugs, no duplicates, at most 10 */
readonly tags: readonly string[];
/** needs a due date */
readonly recurrence: Recurrence | null;
/** ISO 8601 UTC timestamp, e.g. 2026-09-28T09:30:00Z */
readonly createdAt: string;
/** set exactly when done */
readonly completedAt: string | null;
/** manual position, 0 first */
readonly order: number;
}
/** The fields a person edits: what quick-add parses and the add or edit form submits. */
export interface TodoDraft {
readonly title: string;
readonly notes: string | null;
readonly priority: Priority;
readonly due: string | null;
readonly tags: readonly string[];
readonly recurrence: Recurrence | null;
}
/** A todo's verdict, shaped for a form's field messages and an API's validation error. */
export interface TodoValidation {
readonly valid: boolean;
/** field name to message, in field order; empty when valid */
readonly errors: Readonly<Record<string, string>>;
}
Once installed, your code imports each one from the group's module.
validateDraft 35 tests
export function validateDraft(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
validateDraft(title Buy milk, notes —, priority none, due —, tags , recurrence —)→ valid true, errors … a plain title and nothing else is validvalidateDraft(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 consistentvalidateDraft(title , notes —, priority none, due —, tags , recurrence —)→ valid false, errors … a blank title (spaces only) needs a title
import { validateDraft } from "#fune/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.
import { daysInMonth } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { type TodoDraft, type TodoValidation } from "./todo_item_types.ts";
export const MAX_TITLE = 200;
export const MAX_NOTES = 2000;
export const MAX_TAGS = 10;
export const MAX_TAG_LENGTH = 30;
export const MAX_INTERVAL = 999;
const PRIORITIES = ["none", "low", "medium", "high"];
const FREQUENCIES = ["daily", "weekdays", "weekly", "monthly", "yearly"];
/**
* Trim ASCII whitespace only. String.prototype.trim, Python's strip and Rust's
* trim each know a different set of Unicode spaces, so "trimmed" would mean
* three different things; a title stored by one service must read the same
* in the others.
*/
export function trimSpace(text: string): string {
const isSpace = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\v" || c === "\f";
let start = 0;
let end = text.length;
while (start < end && isSpace(text[start])) start++;
while (end > start && isSpace(text[end - 1])) end--;
return text.slice(start, end);
}
/** Length in Unicode code points, the count every language agrees on. */
export function codePointLength(text: string): number {
let n = 0;
for (const _ of text) n++;
return n;
}
function digits(text: string, from: number, to: number): boolean {
for (let i = from; i < to; i++) {
const c = text.charCodeAt(i);
if (c < 48 || c > 57) return false;
}
return true;
}
/** A real calendar date written YYYY-MM-DD, years 0001 to 9999. Never throws. */
export function isIsoDate(text: unknown): boolean {
if (typeof text !== "string" || text.length !== 10 || text[4] !== "-" || text[7] !== "-") return false;
if (!digits(text, 0, 4) || !digits(text, 5, 7) || !digits(text, 8, 10)) return false;
const year = Number(text.slice(0, 4));
const month = Number(text.slice(5, 7));
const day = Number(text.slice(8, 10));
return year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month);
}
/** A tag in normal form: lower-case ASCII letters and digits in runs joined by single hyphens. */
function isTagForm(tag: string): boolean {
if (tag.length === 0 || tag[0] === "-" || tag[tag.length - 1] === "-") return false;
for (let i = 0; i < tag.length; i++) {
const c = tag[i];
const ok = (c >= "a" && c <= "z") || (c >= "0" && c <= "9") || (c === "-" && tag[i - 1] !== "-");
if (!ok) return false;
}
return true;
}
function titleError(title: string): string | null {
const trimmed = trimSpace(title);
if (trimmed.length === 0) return "Enter a title.";
for (const ch of trimmed) {
const code = ch.codePointAt(0) as number;
if (code < 0x20 || code === 0x7f) return "Keep the title to one line, without control characters.";
}
if (codePointLength(trimmed) > MAX_TITLE) return `Use no more than ${MAX_TITLE} characters for the title.`;
return null;
}
function tagsError(tags: readonly string[]): string | null {
if (tags.length > MAX_TAGS) return `Use no more than ${MAX_TAGS} tags.`;
const seen: string[] = [];
for (const tag of tags) {
if (!isTagForm(tag)) return `Tag "${tag}" can only use lower-case letters, digits and single hyphens.`;
if (tag.length > MAX_TAG_LENGTH) return `Tag "${tag}" is longer than ${MAX_TAG_LENGTH} characters.`;
if (seen.includes(tag)) return `Tag "${tag}" is listed twice.`;
seen.push(tag);
}
return null;
}
function recurrenceError(draft: TodoDraft): string | null {
const rule = draft.recurrence;
if (rule === null || rule === undefined) return null;
if (!FREQUENCIES.includes(rule.frequency)) return "Choose how it repeats: daily, weekdays, weekly, monthly or yearly.";
if (!Number.isInteger(rule.interval) || rule.interval < 1 || rule.interval > MAX_INTERVAL) {
return `Repeat every 1 to ${MAX_INTERVAL} days, weeks, months or years.`;
}
if (rule.frequency === "weekdays" && rule.interval !== 1) return "Weekday repeats cannot skip weeks; use an interval of 1.";
if (!isIsoDate(rule.anchor)) return "Enter the repeat start as a real date, YYYY-MM-DD.";
if (draft.due === null || draft.due === undefined) return "A repeating todo needs a due date.";
if (isIsoDate(draft.due) && rule.anchor > draft.due) return "The repeat cannot start after the due date.";
return null;
}
/**
* The edit rules as ordered [field, message] pairs, for validateTodo to merge
* its own checks into field order.
*/
export function draftErrors(draft: TodoDraft): [string, string][] {
const errors: [string, string][] = [];
const title = titleError(draft.title);
if (title !== null) errors.push(["title", title]);
if (draft.notes !== null && draft.notes !== undefined && codePointLength(draft.notes) > MAX_NOTES) {
errors.push(["notes", `Use no more than ${MAX_NOTES} characters for the notes.`]);
}
if (!PRIORITIES.includes(draft.priority)) errors.push(["priority", "Choose a priority: none, low, medium or high."]);
if (draft.due !== null && draft.due !== undefined && !isIsoDate(draft.due)) {
errors.push(["due", "Enter the due date as a real date, YYYY-MM-DD."]);
}
const tags = tagsError(draft.tags);
if (tags !== null) errors.push(["tags", tags]);
const recurrence = recurrenceError(draft);
if (recurrence !== null) errors.push(["recurrence", recurrence]);
return errors;
}
/**
* Check what a person typed into the add or edit form. The title is judged
* trimmed, because the list operations trim it before storing; everything
* else is judged as given.
*/
export function validateDraft(draft: TodoDraft): TodoValidation {
const errors = Object.fromEntries(draftErrors(draft));
return { valid: Object.keys(errors).length === 0, errors };
}validateTodo 16 tests
export function validateTodo(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
validateTodo(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 todovalidateTodo(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 timevalidateTodo(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
import { validateTodo } from "#fune/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.
import { draftErrors, isIsoDate, trimSpace } from "./todo_item_validate_draft.ts"; ← validateDraft, another function of this group · built into the same file, even by a slim install
import { type Todo, type TodoValidation } from "./todo_item_types.ts";
const FIELD_ORDER = ["id", "title", "notes", "priority", "due", "tags", "recurrence", "createdAt", "completedAt", "order"];
/**
* An ISO 8601 UTC timestamp as JavaScript's toISOString and most databases
* write it: YYYY-MM-DDTHH:MM:SS, optional fraction of 1 to 9 digits, then Z.
* Offsets other than Z are refused so that two timestamps compare as strings.
*/
export function isUtcTimestamp(text: unknown): boolean {
if (typeof text !== "string" || text.length < 20) return false;
if (!isIsoDate(text.slice(0, 10)) || text[10] !== "T" || text[13] !== ":" || text[16] !== ":") return false;
const two = (at: number, max: number) => {
const a = text.charCodeAt(at) - 48;
const b = text.charCodeAt(at + 1) - 48;
return a >= 0 && a <= 9 && b >= 0 && b <= 9 && a * 10 + b <= max;
};
if (!two(11, 23) || !two(14, 59) || !two(17, 59)) return false;
let rest = text.slice(19);
if (rest[0] === ".") {
let n = 1;
while (n < rest.length && rest.charCodeAt(n) >= 48 && rest.charCodeAt(n) <= 57) n++;
if (n === 1 || n > 10) return false;
rest = rest.slice(n);
}
return rest === "Z";
}
/**
* Check a stored todo: the draft rules, plus what only a stored item has
* (an id, a trimmed title, timestamps, a completion time exactly when done,
* an order). Use it on anything read back from storage or imported.
*/
export function validateTodo(todo: Todo): TodoValidation {
const found = new Map<string, string>(draftErrors(todo));
if (trimSpace(todo.id).length === 0) found.set("id", "Every todo needs an id.");
if (!found.has("title") && trimSpace(todo.title) !== todo.title) found.set("title", "Remove the spaces around the title.");
if (!isUtcTimestamp(todo.createdAt)) found.set("createdAt", "Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z.");
const completedAt = todo.completedAt ?? null;
if (todo.done && completedAt === null) found.set("completedAt", "A done todo needs the time it was completed.");
else if (!todo.done && completedAt !== null) found.set("completedAt", "An open todo cannot have a completion time.");
else if (completedAt !== null && !isUtcTimestamp(completedAt)) {
found.set("completedAt", "Record when it was completed as a UTC timestamp, e.g. 2026-09-28T09:30:00Z.");
}
if (!Number.isInteger(todo.order) || todo.order < 0) found.set("order", "Order must be a whole number, 0 or more.");
const errors: Record<string, string> = {};
for (const field of FIELD_ORDER) {
const message = found.get(field);
if (message !== undefined) errors[field] = message;
}
return { valid: Object.keys(errors).length === 0, errors };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, 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.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 TypeScript implementation. Install it without the registry with fune add ./todo.item-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.item@1.0.0:typescript.
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 |