Build a kanban task board from the records pattern

Start state: nothing. End state: a drag-and-drop kanban board with workflow columns, priorities, and a due-date calendar — without writing any board code. The trick: the shell's entity-view kit already contains a kanban board; you get it by declaring a statusKey whose select options are your workflow stages.

1. Scaffold with records

pnpm new tracker --title "Tracker" --type webapp --features records
pnpm install
cd apps/tracker && npx convex dev --once && pnpm exec tsc --noEmit

Expected: apps/tracker with features dashboard, settings, records; tsc clean. All edits below are inside apps/tracker.

2. Define the workflow columns

The board groups rows by the field named in statusKey, and its columns come from that field's select options — one option, one column, in order. In convex/recordsShared.ts, replace the 3 sample statuses with a 4-stage workflow (tones color the badges: accent, warn, ok, danger):

// convex/recordsShared.ts
export const STATUS_VALUES = ["backlog", "doing", "review", "done"] as const;
export type RecordStatus = (typeof STATUS_VALUES)[number];
export const STATUS_META: { value: RecordStatus; label: string; tone?: "ok" | "warn" | "danger" | "accent" }[] = [
  { value: "backlog", label: "Backlog" },
  { value: "doing", label: "Doing", tone: "accent" },
  { value: "review", label: "Review", tone: "warn" },
  { value: "done", label: "Done", tone: "ok" },
];

This file is the single source of truth: the backend validators in convex/records.ts and the frontend select options both import it, so a stage can never exist in one place and not the other.

3. Shape the config for a task tracker

In src/features/records/pages, the one CONFIG is the entire frontend. For a task board you want status (columns), priority (triage), and dueDate (calendar + timeline):

const CONFIG: EntityConfig = {
  labels: ["Task", "Tasks"],
  titleKey: "title",
  statusKey: "status",   // board columns = STATUS_META, in order
  dateKey: "dueDate",    // powers calendar + timeline views
  fields: [
    { key: "title", label: "Title", type: "text", required: true },
    { key: "status", label: "Status", type: "select", options: STATUS_META },
    { key: "priority", label: "Priority", type: "select", options: PRIORITY_META },
    { key: "tags", label: "Tags", type: "tags" },
    { key: "dueDate", label: "Due", type: "date" },
    { key: "notes", label: "Notes", type: "longtext" },
  ],
};

PRIORITY_META already ships in recordsShared.ts (low / medium with tone "warn" / high with tone "danger") — keep it as-is. Dropping imageKey, positionKey, and amountKey from the config automatically hides gallery, canvas, and stats totals: availableViews(CONFIG) only offers views the config can power.

4. How drag persists

You do not write drag code. The board calls the onPatch callback the page already wires:

<EntityViews
  cfg={CONFIG}
  rows={rows}
  view={view}
  onOpen={(row) => setEditing(row)}
  onPatch={(id, patch) => run(update({ id: id as Id<"records">, ...patch }))}
  onDelete={(id) => run(remove({ id: id as Id<"records"> }))}
/>

Dragging a card from Doing to Review fires onPatch(id, { status: "review" }); the updateRecord mutation validates against STATUS_VALUES, writes, and Convex reactivity moves the card for every open tab.

If you renamed statuses, existing rows still hold the old values — either clear the table in the Convex dashboard or add a tiny migration mutation. The board shows a column only for values present in STATUS_META.

5. Codegen, typecheck, run

npx convex codegen && pnpm exec tsc --noEmit
pnpm dev

Expected: zero tsc errors. Sign up, open Records, switch to the board view — four columns, cards draggable between them. Switch to calendar: tasks land on their dueDate; drag through timeline for the same data over time. Every view is the same rows, re-projected.

What you built

  • A kanban task tracker: 4 tone-colored workflow columns, drag-to-move persisted via one onPatch line
  • Priority triage and a due-date calendar from two extra field declarations
  • Proof of the pattern: the "board" is just a select field the view kit knows how to group by