# 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 filtered and sorted list that stays live, a filter that lives in the URL, 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 This page is large enough to split. The row types and the pure helpers over them go in a `models.ts` sibling, the route stays in `index.ts`, and the markup plus its handlers stay in `template.ehtml`. A smaller page keeps all of it in `template.ehtml` and imports from `./template` where the route needs it. `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(); } export function parseFilter(param: unknown): Filter { return param === "active" || param === "done" ? param : "all"; } ``` 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 { parseFilter, Todo } from "./models"; import html from "./template"; /* No ordering here. How the list is arranged on screen is a view concern and lives in the template, in byPriority. */ const todos = new LiveTable(); export default function route(req: Request, res: Response) { return new html({ todos: todos.view(), view: { filter: parseFilter(req.query.filter) } }); } ``` A LiveTable is always live: an insert here is broadcast to every page watching this table. There is nothing to turn on. The filter lives in the URL as a query param. `GET /?filter=done` server-renders with Done already applied, so a reload or a shared link lands on the same view. `req.query` holds the query string; `parseFilter` falls back to `all` on anything unrecognized. ## Filtering and sorting `filter()` and `sort()` return plain arrays, and the loop is still live. Iterating the view inside the `e:for` expression takes a dependency, so when a row enters or leaves, the expression re-runs and the loop reconciles the new array against the screen by id. The rows that did not change keep their DOM and their state: ```ehtml