Projects

Todo

A Kanban-style todo app with multiple lists, drag and drop, and time tracking.

Web
Todo kanban board with Backlog, In progress, Done and Trash columns.Todo board with a running timer on the task Build kanban board summary page.Todo edit dialog with title, priority and column fields.

What was my problem?

I wanted to refresh my React knowledge, and I also needed a small todo app and kanban board for another project. Existing tools were either too heavy or didn’t fit how I wanted to organize work. So I built one that covers my own needs: several lists, a board view, and a way to see how much time each task actually takes.

It has multiple lists, drag-and-drop columns, priorities, per-list sort and filter preferences, and per-todo time tracking.

How did I solve it?

Stack

  • Frontend: Vite, React 19 and TypeScript as a single-page app. I chose a plain SPA over Next.js because there’s no server-rendering need.
  • UI: Tailwind CSS v4 and shadcn/ui on Radix primitives, with a dark theme. Drag and drop uses @dnd-kit, including keyboard support.
  • Backend: Supabase for auth, Postgres and realtime, called directly from the browser. Row-level security policies protect each user’s data.
  • State: TanStack Query for server state, with optimistic updates that roll back only the affected item on failure.
  • Forms and tests: react-hook-form and zod for the auth forms. Vitest and Testing Library for tests.

Key decisions

  • A thin, layered data path. All Supabase calls live in services/. Mappers convert between snake_case database rows and camelCase domain entities, and validate enum-like columns on the way in. Hooks wrap the services with TanStack Query. Components never touch the database directly.
  • Realtime writes into the query cache. Changes from other devices flow into the same cache the UI reads from. There is one source of truth and no separate sync layer. For example, the timer stops if another device trashes the todo being tracked.
  • The URL is the source of truth for the selected list. Deep links like /lists/<id> work and survive reloads.
  • Versioned migrations. The database schema lives in migrations, and an audit query checks the RLS policies.
  • Accessibility and polish as part of the work. I ran automated a11y checks, made drag and drop keyboard-operable, and added an offline banner, undo for trashing todos, and code-split screens.

Retrospective

What worked

  • Moving server state to TanStack Query removed a lot of hand-rolled loading and sync code. Optimistic updates and realtime fit into it cleanly.
  • The layered structure (services, mappers, hooks, components) kept changes local and made the hooks easy to test.
  • Small, frequent releases (v0.1 to v0.5) with a written backlog kept the scope under control.
  • shadcn/ui gave me accessible components I could edit, instead of a library I had to fight.

What I’d do differently

  • Start with migrations and RLS. I built against the database first and reconstructed the schema history afterwards. Doing it the other way round would have been less work and safer.
  • Adopt TanStack Query and shadcn from day one. Both replaced earlier approaches (React contexts and hand-written styling), and each migration cost time.
  • Subscribe to realtime beyond the selected list. It currently only listens to the open list, so a change on another device to a different list isn’t picked up until I open it.
  • Add end-to-end tests. Unit and component tests cover a lot, but drag and drop and realtime behavior were mostly checked by hand.
  • Design the offline story deliberately. Right now offline mode is a banner and mutations that keep retrying. It’s not a full offline-first design.