Generated by /office-hours on 2026-04-11 Branch: unknown Repo: flowboard Status: APPROVED Mode: Builder
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.
Live collaboration is the hardest problem in web development. FlowBoard shows all three layers simultaneously:
- Presence — colored cursors floating on the board with user names, online avatars in the header
- CRDT editing — two users typing in the same card description, character by character, with inline cursors and conflict-free merging
- 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.
- 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
- The 30-second first impression (demo + README + code quality) is what matters for recruiters. Optimize for that.
- 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.
- Monorepo structure (Turborepo + pnpm workspaces) is kept to demonstrate engineering maturity.
- Demo mode: scripted 60-second intro choreographing 3 simulated users, then random bot behavior continues organically.
- TypeORM replaced with Prisma for better type safety and code quality. Redis kept for presence (correct production architecture + resume signal).
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.
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
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 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
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.
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 inmain.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.
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.
Persist Y.Doc state to cards.description_yjs (BYTEA) on TWO triggers:
- On disconnect — when the last user leaves the y-websocket room for a card, persist the final state
- 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.
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 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.
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.
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.
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.
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.
Card drag (optimistic) — REST-primary, Socket.io for broadcast:
- User drags card → frontend updates local state immediately (card moves visually)
- Frontend sends
POST /api/cards/:id/move(REST is the source of truth for mutations) - Backend validates: user is board member, target list exists, position is valid
- On success: Backend persists to DB, then broadcasts
card:movedvia 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. - 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."
- On WebSocket disconnect: Frontend detects disconnect, shows "Reconnecting..." banner. Queues pending moves. On reconnect, replays queue via REST. If replay fails, reverts.
- Timeout: If no REST response within 5 seconds, treat as failure and revert.
- 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.
- 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. - Yjs persistence: Persist on last-user-disconnect + 30s debounce timer during active editing.
- Demo mode: Shared demo board, bots run continuously, guests join the same room.
- Auth scope: JWT only. OAuth cut.
- Mobile: Cut for initial launch. Desktop-only.
- 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
- 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
- Initialize the monorepo — Turborepo + pnpm workspaces, NestJS API app, Vite React web app, shared types package. Get
pnpm devrunning both apps with hot reload. - 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.
- Database schema — Prisma schema for users, boards, lists, cards (4 core tables + labels as a stretch). Run initial migration.
- Auth module — JWT register/login/refresh, guards, current-user decorator. Guest user flow for demo mode.
- Board CRUD — create/read boards and lists, basic REST API. Seed a demo board with 5 lists and 17 cards.
- Yjs integration — y-websocket server on
/yjs/path, TipTap editor on frontend, two-tab sync working. Persistence todescription_yjson disconnect + 30s debounce. - Presence system — Redis-backed heartbeats via Socket.io, cursor broadcasting, online user avatars. Board-level only (Yjs Awareness handles in-editor cursors automatically).
- Drag-and-drop — @dnd-kit with fractional indexing (FLOAT positions), optimistic updates with 5s timeout + rollback animation, Socket.io broadcast to room.
- Demo mode — scripted 60-second choreography (see specification above), then random weighted bot behavior.
- Polish — Framer Motion layout animations, TailwindCSS styling, cursor fade-out transitions.
- Deploy + README — Railway/Vercel deploy, architecture diagram, GIF recording, "Why I Built This" section.
| 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 |
Four core tables instead of eleven:
| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| 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() |
| 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() |
| 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() |
| 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).
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.
- 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.