How it works

Architecture & real-time

Three tiers, one WebSocket channel per workspace, and an optimistic client that trusts itself first. Nothing clever, nothing bespoke — every moving part is a standard Supabase primitive.

The stack

Framework
Next.js 16 · App Router · React 19
Language
TypeScript 5 (strict)
Styling
Tailwind CSS 4, dark mode only, tf-* design tokens
Database
Supabase Postgres with row-level security on every workspaced table
Realtime
Supabase Realtime — one WebSocket channel per workspace
Storage
Supabase Storage bucket (task-images)
Auth
Supabase Auth — magic-link OTP only, no passwords
Hosting
Vercel Edge · static marketing + dynamic workspace routes
Architecture diagram showing browser, Vercel edge, and Supabase tiers with data flow arrows.
Three tiers. The browser holds context state; the edge serves routes and auth callbacks; Supabase is the one source of truth.

Client data flow

Every client component reads from a single React context — WorkspaceContext — that holds workspace, members, projects, tasks, notes, feed, and member stats. Three custom hooks feed it:

useWorkspace
Initial load. Awaits the Supabase session, then fetches every workspaced table in parallel. Lazy-loads notes and feed on demand.
useRealtime
Subscribes to the workspace channel, dispatches postgres_changes events to the context setters, and handles presence broadcasts.
useIdentity
Reads localStorage and Supabase auth state. Exposes the current member to every component that needs to attribute a mutation.
Optimistic-first
When a user checks a task, the UI updates before the network round-trip. The client writes to Supabase; if that fails, the local state rolls back. When the realtime echo arrives from the server, we check if local state already matches and drop the duplicate.

Realtime fan-out

Every workspace gets one Supabase Realtime channel, named workspace-<id>. Every client in that workspace subscribes to it. Two event types flow through:

  1. postgres_changes — INSERT, UPDATE, DELETE events on the workspaced tables (tasks, projects, members, project_notes, project_feed, daily_standups, member_stats, notifications), all filtered to the current workspace_id.
  2. broadcast — ephemeral focus-timer presence. When you start a focus session, your client broadcasts { memberId, projectId, active: true }. Nothing gets persisted; it just lights up a live dot on teammates' screens.
Fan-out diagram: one writer pushes to the Supabase hub, which broadcasts to four other workspace clients.
One write, many reads. Supabase handles the fan-out; the client only merges events into context.

Data model

Nine tables, all scoped by workspace_id. Foreign keys cascade on workspace delete. The schema is intentionally narrow — no comments, no labels, no subtasks, no separate priority system beyond the importance flag.

Entity-relationship diagram of the nine tables: workspaces, members, projects, tasks, project_notes, project_feed, daily_standups, member_stats, notifications.
Workspaces at the root. Every other table points back, directly or transitively.

Why this shape

Three decisions are load-bearing:

  • Postgres, not a CRDT. Last-write-wins on a few contested fields is fine for a team of five. A CRDT would add complexity we can't justify.
  • Client is the renderer, not the planner. The client never holds data that isn't in the database. On refresh, you get exactly the same view you left.
  • One channel per workspace. We could split by table for lower cardinality; we don't. Five members * eight tables would be forty subscriptions. One WebSocket is cheaper, and RLS already scopes rows correctly.

Next up

The layer on top of the task list — XP, streaks, standups, and Copilot.

Gamification & Copilot →