Manual Recipes Todo App

Todo App

elements man recipes/todo-app Read as markdown

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

elements create migration 'add todos' -tables=todos
-- 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:

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:

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<Todo>({ 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:

<ul class="todo-list" e:if={countOf(todos, view.filter) > 0}>
  <li e:for={todo of todos.filter((todo) => matches(todo, view.filter)).sort(byPriority)}
      class={["todo-item", todo.done && "is-done", todo.leaving && "is-leaving"]}
      onanimationend={() => onLeaveEnd(todos, todo)}>

Write the filter(...) in the loop's own expression, not behind a helper. The loop re-runs when something it read changes, and an inline predicate reads view.filter each time the rows are walked. Hoisting it into visible(todos, view.filter) reads view.filter once, while the argument is being evaluated, and the loop never learns that the tabs can change it: the list renders once and then ignores every tab click, while the tab's own highlight updates and makes it look like the filter ran.

Do not render every row and hide some with display: none either. A hidden row is still in the dom, still bound, and still counted by anything that walks the list.

The tabs are a plain template that writes to view:

<FilterTab (view: View, filter: Filter, label: string, count: number)>
  <button class={["tab", view.filter === filter && "is-active"]}
          type="button"
          aria-pressed={view.filter === filter}
          onclick={() => view.filter = filter}>
    {label}
    <span class="todo-tab-count">{count}</span>
  </button>
</FilterTab>

view is an object rather than a bare attribute because a handler can mutate an object's fields and have it be live, while reassigning a plain attribute inside a handler is not (elements man html/attributes).

Counts come off the same live view:

function countOf(todos: LiveTable<Todo>, filter: Filter): number {
  return todos.filter((todo) => matches(todo, filter)).length;
}

Adding

function onAdd(todos: LiveTable<Todo>, draft: Draft) {
  let title = draft.title.trim();

  if (title === "") {
    return;
  }

  /* createdAt is set here even though the column defaults to now(): the
     optimistic row is painted from exactly this object, and the order depends
     on it. */
  todos.insert(
    { title, done: false, starred: false, createdAt: new Date() },
    () => draft.title = "",
  );
}

The optimistic row is drawn from the object you pass and nothing else. Any field the template reads has to be in it, including ones the database would have filled in. Leaving createdAt out here sorts the new row as though it had no date until the server answers, and it visibly jumps.

The second argument clears the composer. Use it for UI state only, never to navigate: it runs before the write is sent.

Updating

Spread the row and name what changed:

function onToggle(todos: LiveTable<Todo>, todo: Todo, done: boolean) {
  todos.update({ ...todo, done });
}

function onStar(todos: LiveTable<Todo>, todo: Todo) {
  todos.update({ ...todo, starred: !todo.starred });
}

An update patches the row in place, so the row keeps its dom and only the bindings that read the changed field repaint. Listing every column by hand works too, and says less.

Renaming inline

The editor is one input that swaps in for the label, bound to the buffer:

<input class="todo-text"
       e:if={edit.id === todo.id}
       value={edit.title}
       aria-label="Edit todo"
       oninsert={(e) => (e.target as HTMLInputElement).select()}
       onblur={() => onCommit(todos, edit, todo)}
       onkeydown={(e) => {
         if (e.key === "Enter") {
           (e.target as HTMLInputElement).blur();
         }

         if (e.key === "Escape") {
           onCancelEdit(edit);
         }
       }} />

<button class="todo-label"
        e:else
        type="button"
        aria-label={`Edit ${todo.title}`}
        onclick={() => onEdit(edit, todo)}>
  {todo.title}
</button>
function onEdit(edit: Edit, todo: Todo) {
  edit.id = todo.id;
  edit.title = todo.title;

  flush();
}

function onCommit(todos: LiveTable<Todo>, edit: Edit, todo: Todo) {
  if (edit.id !== todo.id) {
    return;
  }

  edit.id = "";

  let title = edit.title.trim();

  if (title === "") {
    onRemove(todos, todo);
    return;
  }

  if (title === todo.title) {
    return;
  }

  todos.update({ ...todo, title });
}

function onCancelEdit(edit: Edit) {
  edit.id = "";
}

flush() mounts the input synchronously inside the click, which is what lets iOS Safari open the keyboard for it (elements man html/events). Cancelling is one assignment because the todo was never touched.

Deleting, with an animation

/* Matches --duration-normal, the css the leave animation runs with. */
const LEAVE_MS = 220;

function onRemove(todos: LiveTable<Todo>, todo: Todo) {
  todo.leaving = true;

  setTimeout(() => todos.delete(todo), LEAVE_MS);
}

function onLeaveEnd(todos: LiveTable<Todo>, todo: Todo) {
  if (todo.leaving) {
    todos.delete(todo);
  }
}

Two things finish the delete and either may be first. animationend is the fast path and the usual one. The timer is the backstop, because a tab that is not being looked at throttles animations and may never raise the event, and a delete the user asked for has to land regardless.

Whichever arrives second deletes a row that is already gone, and that is a no-op: the table looks the row up by id and returns when it is not there. So neither path needs a flag tracking the other.

Do not use the timer alone. A busy browser can run a 200ms timer seconds late, and the row sits there faded out until it does.

Clearing every done row is the same call in a loop:

function onClearDone(todos: LiveTable<Todo>) {
  for (let todo of todos.filter((todo) => todo.done)) {
    onRemove(todos, todo);
  }
}

Empty states

Three of them, because "no todos at all" and "nothing left to do" are different news:

<div e:else class="todo-empty">
  <p class="todo-empty-title" e:if={todos.length === 0}>Nothing on the list</p>
  <p class="todo-empty-title" e:elseif={view.filter === "active"}>All caught up</p>
  <p class="todo-empty-title" e:else>Nothing finished yet</p>
</div>

Styling it

Change colour first and layout second, and delete the scaffolded markup before you touch layout. See start/patterns for why: a page that is half old markup and half new css looks broken while you work, and if anyone is watching, that is what they see.

Checking it

A green build means it compiles, not that it works. Open two tabs on http://localhost:4000/ and confirm each of these:

  • Adding a todo in one tab makes it appear in the other.
  • Checking one row does not make the rest of the list flash. If it does, the loop is depending on something it should not; read livetable.
  • Deleting a row in the middle removes that row and no other, and leaves no faded row behind.
  • Active hides a row the moment you check it, and Done shows it.
  • Renaming a row and pressing Escape leaves the original title.