Skip to content

Add planning poker: real-time estimation, on the landing page and the shared create flow - #40

Merged
malpou merged 8 commits into
mainfrom
claude/session-gel5nv
Jul 25, 2026
Merged

Add planning poker: real-time estimation, on the landing page and the shared create flow#40
malpou merged 8 commits into
mainfrom
claude/session-gel5nv

Conversation

@malpou

@malpou malpou commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Why

Every capability the tool ships today is an asynchronous poll. Agile teams have a synchronous need: estimating a backlog together, live, one item at a time, with everyone revealing at once so nobody anchors on the loudest voice. No async poll type can express that.

This adds a planning-poker room — a controller-run, real-time estimation space — and then makes it a real part of the product: discoverable from the landing page, created from the same page as polls, and at feature parity with them.

What's in it

The capability

  • New capability, not a new poll_type — planning poker shares no data with the events model, so it lives in its own tables and routes. A room has no options, invitees, timezone, answering mode, or choice toggles; the only field it shares with a poll is a title.
  • Two capability links per room: a private controller link and a shared join link (cookie-remembered identity; a refresh resumes the same seat).
  • Per-item state machine driven only by the controller: waiting → voting → revealed. Votes stay hidden during voting (only who voted shows), then flip face-up together on reveal.
  • Modified Fibonacci deck 0 1 2 3 5 8 13 20 40 100 plus ? / / .
  • Agreement signal on reveal: agree (all equal → pre-fills the suggestion) / close (one adjacent step) / spread (more than one step apart, any ∞, or no numeric votes). ∞ forces a spread; ☕ raises an advisory break hint.
  • Durable results log in D1; the room, its items, and each final estimate persist.

Discovery and creation

  • Landing page leads its body with planning poker — above the poll examples, badged as new, since nobody arrives looking for a capability they don't know exists. It carries a one-tap reveal example (four face-down cards flip together, agreement strip appears, nothing persisted) and its own call-to-action.
  • One create page for both tools. /create opens by asking what you're making. Picking a room reduces the form to a room name; picking a poll leaves the existing form untouched. Two form actions rather than one branching action, so neither validator knows about the other's fields.
  • /poker/new removed. It was never released, linked, or indexed, so there is no link in the wild to preserve.
  • Room creation is now indexable like poll creation; controller and join pages stay noindex with every other token page.

Feature parity with polls

  • Highlighter: rooms were the one capability with a hardcoded blue accent. They now take an organizer-picked highlighter, stored on the room and worn by every one of its pages.
  • Language: rooms always fell back to the base locale even though all poker strings were already translated into five languages. A room now records the language picked at creation and renders in it for everyone who joins. Stored on the room rather than in the URL, because one join link serves the whole team.
  • Email: the same optional field poll creation offers. The controller gets the room's private link immediately, and a summary of every decided item when they close the room. Unlike a poll organizer's address, a room's is stored — the summary is sent arbitrarily later — and never leaves the server.

Fixes found by using it

  • Reveal is held until every present estimator has voted, and names who it is waiting on. Observers and dropped-out seats never hold it up, so one absentee cannot deadlock a round.
  • The recorded estimate stays inside what was voted — only the deck numerals between the lowest and highest card cast, so a round that split 3/8 cannot be recorded as 40.
  • Enter submits the room's three text fields; they were mouse-only.
  • A closed room stops offering its join link, which no longer admits anyone.

Transport decision (documented in the change's design.md)

The original design proposed a Durable Object + WebSocket push. Mid-implementation this hit a concrete constraint: @sveltejs/adapter-cloudflare exports only its own worker's default and overwrites main on every build, so exporting a DO class fights the adapter and the single-worker e2e harness. Live session state stays in D1 (room phase, heartbeat-presence roster, per-participant votes) with clients short-polling a JSON state endpoint (~1s).

Costing the loop afterwards: a six-person, one-hour room is ~21,600 requests and ~21,600 D1 writes, which sits inside the Workers paid plan's included allowances for several hundred sessions a month. A Durable Object would cut message volume ~100×, but the case for it is write throughput and push latency, not cost — D1 is a single SQLite writer shared with the whole poll product. As a cheap mitigation the heartbeat write is now throttled to once per 5s per seat against a 15s presence window, cutting poker's D1 writes ~5× with no behavior change. The data model already captures everything push would, so a DO upgrade needs no schema change.

Security

  • The token is the path credential; every load and endpoint authorizes it in D1 and 404s on an unknown token.
  • Control actions require the controller token (403 otherwise); the join token grants only join/vote/heartbeat.
  • Vote privacy is a server property: the state endpoint never includes another seat's card value before the reveal — enforced in one place, buildSnapshot, and unit-tested.
  • castVote is guarded in SQL (voting phase + registered estimator), so observers and non-joined callers cannot vote.
  • A room's stored email address is never included in any client payload; controller-typed item titles are HTML-escaped in the summary email.

Spec and design invariants

  • openspec/specs/planning-poker/spec.md and openspec/specs/landing-page/spec.md are synced as the source of truth, each colocated with its Playwright suite.
  • openspec/specs/DESIGN.md updated: rooms take an organizer-picked highlighter (was a fixed blue), plus the landing section's rules, badge, and reveal example.
  • openspec/specs/PROJECT.md previously had no mention of planning poker at all — it now documents the capability's purpose, token kinds, data model, routes, and the D1-versus-Durable-Object note.
  • All UI strings via Paraglide across all five locales.
  • OpenSpec changes archived at openspec/changes/archive/2026-07-24-add-planning-poker and .../2026-07-25-unify-poker-creation.

Verification

  • bun run check0 errors / 0 warnings
  • bun run lintclean
  • bun run test141 unit tests (deck, agreement signal, estimate range, reveal gating, snapshot privacy, email composition)
  • bun run test:e2e235 passed, full suite against the real Worker + local D1

Migration and deploy

Four additive D1 migrations, nothing to backfill:

  • 0011_planning_poker.sql — the capability's tables
  • 0012_poker_room_email.sql — the controller's stored address
  • 0013_poker_room_locale.sql — the room's language
  • 0014_poker_room_accent.sql — the room's highlighter

No new binding, no new secret, no wrangler.toml change.

Important

Planning poker has never been released. main has no poker files, so production currently serves 404 for every /poker URL, and the remote database has never had any of these migrations applied. The landing page now advertises the tool, so the deploy order matters: merge → wrangler d1 migrations apply family-date-poll --remote → deploy. Shipping the landing page without the migrations would advertise a tool that 404s.

Notes for reviewers

  • Scenario coverage is split deliberately: realtime and UI scenarios have Playwright tests; pure logic (agreement signal, estimate range, reveal gating, vote privacy, email bodies) is covered by fast unit tests.
  • The realtime e2e tests carry a 90s per-test timeout — up to three browser contexts each polling ~1s.
  • playwright.config.ts sets reuseExistingServer: !CI, which will silently reuse a stale Worker left on :8787 by a killed run. Worth knowing before debugging a confusing local failure.

malpou added 8 commits July 24, 2026 21:43
Add the OpenSpec change artifacts for a controller-run, real-time
planning poker room: Fibonacci deck with ?/infinity/coffee cards,
per-item waiting -> voting -> revealed phases with hidden votes and a
synchronized reveal, an agree/close/spread agreement signal, and
controller-recorded final estimates persisted as a room results log.

Introduces the tool's first real-time layer (a Durable Object per room
over WebSocket hibernation) while leaving the async event capabilities
untouched. Includes proposal, design, delta spec (11 requirements / 28
scenarios), and tasks.
Use the modified Fibonacci deck (0 1 2 3 5 8 13 20 40 100) and make the
controller's optional participation as an estimator explicit across the
proposal, design, and spec (adds a controller-votes scenario).
Transport-agnostic slices, fully verified:
- Agreement-signal logic + deck (src/lib/logic/poker.ts) with 10 unit
  tests: agree/close/spread by deck-index span, infinity forces spread,
  coffee raises the break hint, only agree pre-fills a suggestion.
- Migration 0011: poker_rooms + poker_rounds (durable skeleton + final
  estimates; live phase/votes are not persisted).
- Row types (PokerRoomRow, PokerRoundRow, RoomStatus).
- E2e seed/read helpers for rooms + rounds (e2e-poker-* family).

All 124 unit tests pass, svelte-check clean, lint clean, 0011 applies.
The real-time transport (Durable Object vs D1-backed live state) is the
remaining architectural decision and is deferred to the next step.
Adopt the D1-backed real-time transport (short-polled state endpoint)
over a Durable Object, per the decision that adapter-cloudflare can't
cleanly export a DO. Realign proposal/design/spec to match.

- Migration 0011 extended: room phase + active_round_id + rev counter,
  poker_participants (heartbeat-presence roster), poker_votes (active
  item's transient votes).
- Live-state row types (RoomPhase, ParticipantRole, PokerParticipantRow,
  PokerVoteRow) + card text encode/decode helpers.
- pokerProvider (src/lib/data/poker.ts): self-contained D1 provider for
  room create, token resolution, roster/votes/results reads, and the
  vote + controller commands (open/reveal/revote/finalize/close), each
  bumping rev; vote lands only while phase='voting' (guarded in SQL).
- buildSnapshot (src/lib/logic/poker-snapshot.ts): the single vote-
  privacy enforcement point, with tests proving no card leaks before
  reveal, own-vote echo, presence window, and the revealed signal.

128 unit tests pass, svelte-check + lint clean, 0011 applies fresh.
Full D1-backed real-time flow, verified end-to-end against the real
Worker + local D1 (create -> open -> join -> vote -> reveal -> finalize
-> close), including the privacy gate and control-auth:

- Endpoints: GET /poker/api/[token]/state (viewer snapshot + heartbeat),
  POST /poker/api/[token]/command (join/vote/heartbeat/leave +
  controller open/reveal/revote/finalize/close). Token resolves to
  room+authority; control actions are controller-only (403 otherwise);
  unknown token 404s; closed room refuses mutations.
- Pages: /poker/new (create), /poker/c/[token] (controller console),
  /poker/j/[token] (participant join + vote). noindex.
- Client poll loop (RoomClient, ~1s) applying each command's fresh
  snapshot; graceful leave on pagehide.
- Components: Card, Deck, Roster (presence + synchronized reveal flip),
  Signal (agree/close/spread strip + distribution + break hint).
- Server helpers: token->room resolution, per-room pid cookie, snapshot
  assembly. Observer/estimator + phase enforced in SQL on castVote.
- 49 poker message keys across all five locales (no em-dashes).

svelte-check 0 errors/0 warnings, lint clean, 128 unit tests pass.
- Playwright spec (openspec/specs/planning-poker/planning-poker.spec.ts):
  11 tests over two/three browser contexts against the real Worker +
  local D1, polling the live state endpoint. Covers create->console,
  live join/roster, refresh resumes seat, closed room, open voting live,
  participant cannot drive phases, votes hidden until reveal + flip,
  agree + record + persist, spread, infinity forces spread, close. All
  11 pass; realtime flows get a 90s per-test timeout.
- DESIGN.md: the planning-poker rules (fixed blue accent, card deck,
  face-down back, synchronized reveal flip, roster, agreement signal).
- Fixes found by the e2e run:
  - wipeRoom NULLs active_round_id first (rooms<->rounds FK cycle).
  - Drop the pagehide auto-leave: it fired on reload and dropped the
    seat, breaking "refresh resumes the same seat"; presence window now
    handles departure.
  - Roster shows present seats only, so a departed seat is removed.
- tasks.md rewritten to the shipped D1-backed design; boxes checked.

svelte-check 0/0, lint clean, 128 unit tests, poker e2e 11/11.
- Create openspec/specs/planning-poker/spec.md (11 requirements / 29
  scenarios), the source-of-truth capability spec, colocated with its
  Playwright suite.
- Archive openspec/changes/add-planning-poker ->
  archive/2026-07-24-add-planning-poker.
- Landing page leads its body with a planning-poker section: new badge,
  one-tap reveal example, own call-to-action.
- Room creation moves onto /create behind a "what are you making" choice;
  /poker/new removed.
- Rooms reach parity with polls: organizer-picked highlighter, language
  fixed at creation, optional email on the create form (room link now,
  results summary on close).
- Reveal is held until every present estimator has voted; the recorded
  estimate is limited to the range actually voted.
- Enter submits the room's text fields; a closed room stops offering its
  join link.
- Heartbeat write throttled to 5s against the 15s presence window.
@malpou
malpou force-pushed the claude/session-gel5nv branch from 6aa3d34 to f1ffc9f Compare July 25, 2026 08:46
@malpou malpou changed the title Add planning poker (real-time story-point estimation) Add planning poker: real-time estimation, on the landing page and the shared create flow Jul 25, 2026
@malpou
malpou merged commit 3ca7675 into main Jul 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant