Skip to content

Latest commit

 

History

History
332 lines (252 loc) · 20.7 KB

File metadata and controls

332 lines (252 loc) · 20.7 KB

Design: FlowBoard — Real-time Collaborative Kanban Board

Generated by /office-hours on 2026-04-11 Branch: unknown Repo: flowboard Status: APPROVED Mode: Builder

Problem Statement

Build a portfolio project that demonstrates mastery of real-time collaboration (WebSockets, presence, CRDT), complex frontend (drag-and-drop with optimistic updates), and production-grade system design. The project must impress recruiters in 30 seconds without requiring a second user to be online.

What Makes This Cool

Live collaboration is the hardest problem in web development. FlowBoard shows all three layers simultaneously:

  1. Presence — colored cursors floating on the board with user names, online avatars in the header
  2. CRDT editing — two users typing in the same card description, character by character, with inline cursors and conflict-free merging
  3. Optimistic sync — cards drag instantly, the server validates and broadcasts, rollback if rejected

The demo mode with simulated bots makes this visible without needing a second browser tab. A recruiter opens the page, sees 3 users collaborating live in 5 seconds.

Constraints

  • Solo developer, targeting 6-8 weeks of focused work (~80-100 hours). The dual WebSocket integration (Socket.io + y-websocket) and demo bot choreography each have sharp edges that will eat debugging time.
  • Must be deployable (Railway/Render + Vercel)
  • Demo mode must work without authentication (recruiter clicks and sees magic)
  • Code quality matters as much as features (recruiters will open random files)
  • Must demonstrate both frontend AND backend skills

Premises

  1. The 30-second first impression (demo + README + code quality) is what matters for recruiters. Optimize for that.
  2. Feature set ruthlessly cut to the real-time core: presence, CRDT editing, optimistic drag-and-drop, demo bots. CRUD filler (comments, attachments, activity logs) is dropped.
  3. Monorepo structure (Turborepo + pnpm workspaces) is kept to demonstrate engineering maturity.
  4. Demo mode: scripted 60-second intro choreographing 3 simulated users, then random bot behavior continues organically.
  5. TypeORM replaced with Prisma for better type safety and code quality. Redis kept for presence (correct production architecture + resume signal).

Cross-Model Perspective

An independent Claude subagent reviewed this plan cold:

  • Coolest version not considered: A "time-travel replay" mode. Record every CRDT operation and cursor movement during the demo, then let recruiters scrub a timeline slider to replay the entire collaborative session. Shows understanding of operation logs and event sourcing. Massive "wow" factor.
  • Key insight: "This person doesn't want to build another Trello clone. They want a technical demo that performs. They're building a stage, not a SaaS."
  • 50% there with: Liveblocks or tldraw architectures. Study how they implement presence and CRDT sync, then build your own layer. Don't use them as dependencies — the point is proving you can build it.
  • Weekend build order (aspirational, not a real estimate): Scaffold (2h) → Yjs sync (4h) → Presence cursors (4h) → DnD with fractional indexing (6h) → Demo bots (4h) → Polish + deploy (4h). Note: this assumes zero debugging time for WebSocket integration, which is unrealistic. Real estimate for the full Approach B scope is 6-8 weeks.

Approaches Considered

Approach A: "The Stage" (Minimal Viable Wow)

Build ONLY what a recruiter sees in 60 seconds. Board with lists + cards, real-time drag-and-drop, presence cursors, and scripted demo mode. No auth. No CRUD features.

  • Effort: S (2-3 weeks, ~24-40 hours)
  • Pros: Ships fastest, every feature visible in 30 seconds, tight codebase
  • Cons: No auth = no "full-stack" signal, might feel like a demo not a product
  • Completeness: 5/10

Approach B: "The Full-Stack Demo" (Chosen)

Core real-time features + lightweight auth (JWT login/register) + single workspace with boards. Demo mode runs alongside auth (guests skip login). Prisma + Redis + Turborepo.

  • Effort: M (6-8 weeks, ~80-100 hours)
  • Pros: Shows frontend AND backend, auth + board CRUD proves real app capability, demo mode for instant wow
  • Cons: 2x time of Approach A, auth/workspace code is expected not impressive
  • Completeness: 7/10

Approach C: "The Technical Showcase" (Maximum Signal)

Approach B + time-travel replay + interactive architecture deep-dive page explaining CRDT, presence, and fractional indexing.

  • Effort: L (8-10 weeks, ~120+ hours)
  • Pros: Multi-layered wow, technical deep-dive serves as living README, time-travel is genuinely unique
  • Cons: Risk of never shipping, 3-4x time, time-travel replay may have edge cases
  • Completeness: 9/10

Recommended Approach

Approach B: "The Full-Stack Demo" — balances wow-factor with shipping speed. Every minute of development is visible to the recruiter. Auth proves backend capability. Demo mode proves the real-time magic works. 6-8 weeks is realistic for a focused solo developer, accounting for WebSocket integration debugging.

Architecture Decisions (resolved)

Dual WebSocket Servers: Socket.io + y-websocket

Socket.io (via @nestjs/websockets) handles board-level sync (card moves, list reorders, presence heartbeats). y-websocket handles CRDT document sync for collaborative editing.

Solution: Separate WebSocket paths on the same HTTP server.

  • Socket.io: default path /socket.io/ (NestJS Gateway handles this automatically)
  • y-websocket: custom path /yjs/ with a manual WebSocket upgrade handler in main.ts
// In main.ts, before app.listen():
const yjsServer = setupYjsWebSocket(prismaService);
app.getHttpServer().on('upgrade', (req, socket, head) => {
  if (req.url?.startsWith('/yjs/')) {
    yjsServer.handleUpgrade(req, socket, head, (ws) => {
      yjsServer.emit('connection', ws, req);
    });
  }
  // Socket.io handles its own upgrade on /socket.io/
});

This is a multi-day integration task. Spike it in week 1 before building anything else.

Real-time Responsibility Boundary

Two systems handle real-time state. Clear boundary prevents overlap:

Concern System Why
Who's online on a board Redis + Socket.io Board-level, survives page refreshes
Board cursor positions Socket.io presence events Low-frequency (every 2s), broadcast to room
Card description editing Yjs + y-websocket Character-level sync, CRDT conflict resolution
In-editor cursor positions Yjs Awareness protocol Comes free with y-websocket, no extra work
Card moves/creates/deletes Socket.io events Optimistic updates + server validation

Redis handles board-level online status and cursor positions. Yjs Awareness handles in-editor cursors. No overlap.

Yjs Persistence Strategy

Persist Y.Doc state to cards.description_yjs (BYTEA) on TWO triggers:

  1. On disconnect — when the last user leaves the y-websocket room for a card, persist the final state
  2. On a 30-second debounce timer — while editing is active, debounce persistence to avoid excessive writes

The 30-second debounce timer ensures state is persisted even if last-disconnect detection races (two users disconnecting within milliseconds). On server restart, any in-memory Y.Doc state is lost for rooms with active debounce timers. Acceptable for a portfolio project. Recovery: next user to open the card loads from the last persisted state.

Also update description_text (plaintext fallback) on each persistence for search/display contexts where rich text isn't needed.

Demo Mode Architecture

Shared demo board, not per-recruiter. One demo board runs continuously with 3 bots. All recruiter guests join the same board room and see the same bots.

Why: per-recruiter boards create N bot processes and N board instances. For a portfolio project that might get 0-5 concurrent visitors, the complexity isn't worth it. A shared demo board means bots run once, all visitors see them, and the "other people are here" feeling is real (other recruiters ARE real users alongside bots).

is_demo boolean on the board table gates this. Demo boards skip auth, use a guest user, and prevent destructive mutations (delete board, delete all cards).

Guest User Model

Guest users accessing the demo board are anonymous sessions with no database row. They receive a temporary JWT with a generated UUID, a random name ("Guest-{short_id}"), and a random color from the palette. This JWT has a 24-hour expiry and is stored in memory (not persisted).

Guests can: view the board, open cards, see collaborative editing, and observe bot activity. Guests cannot: move cards, edit descriptions, or modify any board state. They are read-only observers of the demo.

This avoids polluting the users table with ephemeral rows. The email and password_hash columns remain NOT NULL for real users. Guest JWTs contain a role: "guest" claim that the guards check.

Demo Bot Implementation

Bots operate server-side, manipulating state directly via service methods. They do NOT connect via WebSocket. Instead, they call the same service-layer functions that the controllers call, then broadcast the resulting Socket.io events to the room. This means:

  • Bots don't count against WebSocket connection limits
  • No need for bot authentication or WebSocket client setup
  • Bot actions are deterministic (no network latency or race conditions)
  • The Socket.io broadcast makes bot actions visible to all connected clients

Bot actions that fail (e.g., card no longer exists) are silently skipped. The choreography advances to the next timed action. After the scripted phase, failed random actions are discarded.

Demo Bot Choreography (60-second scripted intro)

Second 0-3:   Page loads. Board renders with 5 columns, 17 pre-seeded cards.
              3 bot avatars appear in header one by one (Maria, Carlos, Ana).
Second 3-8:   Maria's cursor moves from left edge to "In Progress" column.
              "Maria is viewing In Progress" indicator appears.
Second 8-15:  Maria drags "Build profile settings page" from "To Do" to "In Progress".
              Card animates smoothly. Activity log shows "Maria moved card."
Second 15-22: Carlos opens "API: user preferences endpoint" card.
              Card modal slides in. Carlos's cursor appears in the description editor.
Second 22-30: Carlos types "Need to handle pagination for the..." character by character.
              Recruiter sees live text appearing with Carlos's colored cursor.
Second 30-35: Ana adds a "Design" label to "Design system: Button variants" in Review.
              Label appears with a subtle animation.
Second 35-42: Maria opens the same card Carlos is editing.
              Two cursors now visible in the editor. Maria types "Also consider dark mode."
Second 42-50: Ana drags a card from "Review" to "Done".
              Celebratory subtle effect (opacity change, strikethrough).
Second 50-60: All three cursors move independently across the board.
              Natural-looking mouse movement (Bezier curves, not linear).

After 60s:    Bots switch to random behavior (random action every 3-8 seconds).
              Weighted preferences: Maria prefers card moves, Carlos prefers typing,
              Ana prefers labels and list navigation.

Fractional Indexing: FLOAT with rebalancing

Using FLOAT (DOUBLE PRECISION) for position columns, not string-based keys. Rationale: simpler implementation, fewer columns, standard SQL ordering.

Precision limit: IEEE 754 doubles have ~15 significant decimal digits. After ~50 dense insertions between two adjacent positions, precision degrades.

Mitigation: Rebalancing triggered after 50 insertions between the same pair of positions (counter-based, not threshold-based, to avoid magnitude sensitivity):

  • Fetch all items in the list/board ordered by position
  • Reassign positions as uniform intervals (1.0, 2.0, 3.0, ...)
  • Batch UPDATE in a transaction

For a portfolio project with ~20 cards per list, this will rarely if ever trigger. But having the rebalancing code shows the engineer thought about it.

CRDT Scope

Yjs is used only for card descriptions via TipTap. Card titles, list names, and board names use standard REST PATCH endpoints with last-write-wins. The CRDT complexity is focused where it provides the most visible value: the collaborative editor where two cursors type simultaneously.

Optimistic Update Failure Modes

Card drag (optimistic) — REST-primary, Socket.io for broadcast:

  1. User drags card → frontend updates local state immediately (card moves visually)
  2. Frontend sends POST /api/cards/:id/move (REST is the source of truth for mutations)
  3. Backend validates: user is board member, target list exists, position is valid
  4. On success: Backend persists to DB, then broadcasts card:moved via Socket.io to all other clients in the room. Emitter gets 200 OK. No client-emitted Socket.io event needed — the server broadcasts after REST success.
  5. On failure (HTTP 4xx): Frontend reverts local state (card snaps back to original position with a 200ms ease-out animation). Toast notification: "Couldn't move card. Try again."
  6. On WebSocket disconnect: Frontend detects disconnect, shows "Reconnecting..." banner. Queues pending moves. On reconnect, replays queue via REST. If replay fails, reverts.
  7. Timeout: If no REST response within 5 seconds, treat as failure and revert.

Decisions cut from scope

  • OAuth (Google login): Cut. JWT-only auth is sufficient for the portfolio. OAuth adds provider setup complexity (Google Cloud Console, callback URLs, env vars) with minimal recruiter-visible value.
  • Mobile responsiveness: Cut for initial launch. Desktop-only is fine. Add a "Best viewed on desktop" notice on mobile viewports. Can be added later if time permits.
  • Rate limiting on demo path: NestJS throttler module for REST endpoints (60 requests/minute for guest users). WebSocket connections limited to 20 per IP (Socket.io only, since bots don't use WebSocket connections). Bots operate server-side via direct service calls, so they are exempt from all connection and rate limits.

Resolved Questions

  1. Deployment: Railway (backend + PostgreSQL + Redis), Vercel (frontend). Docker Compose for local dev. y-websocket runs on a separate path (/yjs/) on the same HTTP server, so no additional deploy target needed.
  2. Yjs persistence: Persist on last-user-disconnect + 30s debounce timer during active editing.
  3. Demo mode: Shared demo board, bots run continuously, guests join the same room.
  4. Auth scope: JWT only. OAuth cut.
  5. Mobile: Cut for initial launch. Desktop-only.

Success Criteria

  • A recruiter opens the URL and sees live collaboration within 5 seconds (no loading screens, no sign-up walls)
  • Two browser tabs can drag cards and see changes sync in real-time (< 200ms latency)
  • Opening a card in both tabs shows collaborative editing with visible cursors
  • The README includes an architecture diagram, a GIF of the demo, and clear "why I built this" section
  • Opening any random source file shows clean, well-typed code with consistent patterns
  • The codebase builds and deploys cleanly from a fresh clone

Distribution Plan

  • Frontend: Vercel (auto-deploy on push to main, preview deploys on PRs)
  • Backend: Railway or Render (Docker-based deploy, auto-scaling)
  • Database: Railway PostgreSQL or Render PostgreSQL
  • Redis: Railway Redis or Upstash (serverless Redis)
  • CI/CD: GitHub Actions for linting, type-checking, and tests on every PR

Next Steps

  1. Initialize the monorepo — Turborepo + pnpm workspaces, NestJS API app, Vite React web app, shared types package. Get pnpm dev running both apps with hot reload.
  2. Spike: dual WebSocket integration — Prove Socket.io + y-websocket can coexist on the same NestJS HTTP server with separate paths. This is the highest-risk integration. Do it first.
  3. Database schema — Prisma schema for users, boards, lists, cards (4 core tables + labels as a stretch). Run initial migration.
  4. Auth module — JWT register/login/refresh, guards, current-user decorator. Guest user flow for demo mode.
  5. Board CRUD — create/read boards and lists, basic REST API. Seed a demo board with 5 lists and 17 cards.
  6. Yjs integration — y-websocket server on /yjs/ path, TipTap editor on frontend, two-tab sync working. Persistence to description_yjs on disconnect + 30s debounce.
  7. Presence system — Redis-backed heartbeats via Socket.io, cursor broadcasting, online user avatars. Board-level only (Yjs Awareness handles in-editor cursors automatically).
  8. Drag-and-drop — @dnd-kit with fractional indexing (FLOAT positions), optimistic updates with 5s timeout + rollback animation, Socket.io broadcast to room.
  9. Demo mode — scripted 60-second choreography (see specification above), then random weighted bot behavior.
  10. Polish — Framer Motion layout animations, TailwindCSS styling, cursor fade-out transitions.
  11. Deploy + README — Railway/Vercel deploy, architecture diagram, GIF recording, "Why I Built This" section.

Tech Stack (Final)

Layer Technology Why
Backend NestJS (TypeScript) Structured, decorators, excellent WebSocket support
Database PostgreSQL + Prisma Type-safe queries, clean migrations, readable schema
Real-time Socket.io (@nestjs/websockets) Board sync (card moves, presence), path: /socket.io/
CRDT Yjs + y-websocket + TipTap Collaborative editing on card descriptions, path: /yjs/
Cache Redis Presence state, room management, production-correct architecture
Auth JWT (access + refresh tokens) Standard, stateless. Refresh tokens stored in HTTP-only cookies, rotated on each use. Guest users get read-only JWTs (no DB row, 24h expiry).
Frontend React + Vite + TailwindCSS Fast dev, utility-first styling
State Zustand (local) + Tanstack Query (server) Clean separation of concerns
Drag & Drop @dnd-kit/core + @dnd-kit/sortable Accessible, composable, well-maintained
Animations Framer Motion Layout animations, cursor transitions
Router React Router v7 Standard routing
Monorepo Turborepo + pnpm workspaces Shared types, parallel builds

Database Schema (Trimmed)

Four core tables instead of eleven:

users

Column Type Notes
id UUID PK
email VARCHAR(255) UNIQUE, NOT NULL
password_hash VARCHAR(255) NOT NULL
name VARCHAR(100) NOT NULL
avatar_url VARCHAR(500) NULLABLE
color VARCHAR(7) Auto-assigned hex for cursor/avatar
created_at TIMESTAMP DEFAULT now()

boards

Column Type Notes
id UUID PK
name VARCHAR(100) NOT NULL
description TEXT NULLABLE
background_color VARCHAR(7) Board hex color
is_demo BOOLEAN DEFAULT false — demo boards for guest users
created_by UUID FK → users.id
created_at TIMESTAMP DEFAULT now()

lists

Column Type Notes
id UUID PK
board_id UUID FK → boards.id, ON DELETE CASCADE
name VARCHAR(100) NOT NULL
position FLOAT Fractional indexing
created_at TIMESTAMP DEFAULT now()

cards

Column Type Notes
id UUID PK
list_id UUID FK → lists.id, ON DELETE CASCADE
title VARCHAR(255) NOT NULL
description_text TEXT Plaintext fallback
description_yjs BYTEA Binary Y.Doc state (CRDT)
position FLOAT Fractional indexing within list
cover_color VARCHAR(7) NULLABLE
due_date TIMESTAMP NULLABLE
created_by UUID FK → users.id
created_at TIMESTAMP DEFAULT now()

Optional stretch tables: card_labels, card_label_assignments (for visual flair in the demo).

Indexes

CREATE INDEX idx_cards_list_position ON cards (list_id, position);
CREATE INDEX idx_lists_board_position ON lists (board_id, position);
CREATE INDEX idx_boards_demo ON boards (is_demo) WHERE is_demo = true;

These cover the primary query patterns: loading a board (lists by board + position), loading a list (cards by list + position), and finding the demo board.

What I noticed about how you think

  • You came with a 917-line architecture plan before the first line of code. That's not common. Most people start coding and figure it out as they go. You planned first.
  • When I pushed on cutting features, you agreed immediately. No attachment to scope, just "what makes the best impression?" That's taste.
  • You pushed back on the monorepo question specifically. You didn't just agree with everything. You had a reason: monorepo setup IS a signal of engineering maturity, and you wanted to demonstrate it.
  • You chose "scripted intro + random bots" for the demo instead of pure random. That's a product instinct, thinking about the first 60 seconds as a designed experience, not just a technical demo.