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
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.
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:
- 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. - 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.
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.
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 →