# Todo App A single-page todo list: add, rename inline, check off, star, delete, and filter by All / Active / Done. Every change appears in every open tab without any extra wiring, because one `LiveTable` backs the page. This is the recipe to read first. It is small enough to hold in your head and it exercises the pieces most apps need: an optimistic insert, a live filtered and sorted view, partial updates, and a delete that animates. ## Migration ```bash elements create migration 'add todos' -tables=todos ``` ```sql -- add todos create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table todos ( id uuid primary key default uuidGenerateV7(), title text not null, done boolean not null default false, starred boolean not null default false, createdAt timestamptz not null default now(), updatedAt timestamptz not null default now() ); create index todosDoneCreatedAt on todos (done, createdAt desc); create trigger todosTouchUpdatedAt before update on todos for each row execute function touchUpdatedAt(); ``` ## The model `app/pages/home/models.ts`: ```ts export interface Todo { id: string; title: string; done: boolean; starred: boolean; createdAt: Date; /** Client-only: the row is playing its leave animation. */ leaving?: boolean; } export type Filter = "all" | "active" | "done"; export interface View { filter: Filter; } export interface Draft { title: string; } /** * The row being renamed, and the text so far. A buffer rather than fields on * the row, so typing never touches the todo. */ export interface Edit { id: string; title: string; } export function matches(todo: Todo, filter: Filter): boolean { if (filter === "active") { return !todo.done; } if (filter === "done") { return todo.done; } return true; } export function byPriority(a: Todo, b: Todo): number { if (a.starred !== b.starred) { return a.starred ? -1 : 1; } return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } ``` Two things to copy from this file. **Keep view state off the row where you can.** `editing` and `saved` fields on a `Todo` would mean a half-typed title is a todo: it is what a second tab sees while you are still typing, and cancelling has to remember what to put back. An `Edit` buffer beside the table has neither problem. `leaving` stays on the row because it is per-row, several rows can be leaving at once, and it decides nothing but a css class. **`createdAt` is parsed on both sides of the comparator.** It is a `Date` on a row you just created and a string on one that arrived over the wire. ## The route `app/pages/home/index.ts`: ```ts import { Request, Response, LiveTable } from "@elements/app"; import { Todo } from "./models"; import html from "./template"; /* Two orderings, on purpose. `sort` is the ORDER BY the query runs with, so a page arrives already in a sensible order. How the list is arranged on screen is a view concern and lives in the template, in byPriority. */ const todos = new LiveTable({ sort: "createdAt desc" }); export default function route(req: Request, res: Response) { return new html({ todos }); } ``` Realtime is the default, so an insert here is broadcast to every page watching this table. There is nothing to turn on. ## Filtering and sorting `filter()` and `sort()` return a live view of the table, not a copy. An `e:for` over one is fed a single insert, delete or move when something changes, so the rows that did not change keep their dom and their state: ```html