My team estimates stories with planning poker. For a long time we used a free site that was covered in ads and looked like it was made in 2009. It worked, but nobody enjoyed opening it.

So I built my own: Guesstimato. Everyone picks a card, the cards flip at the same time, you get the stats, and you can throw a tomato at whoever voted 13. 🍅

Claude Code wrote most of the code. This post is about how that went, with the actual timeline and some of the code, because "AI built my app" posts are usually light on both.

The timeline

These are the real commit times (GMT+2):

Time Commit
Nov 3, 19:22 Shared domain logic: decks, stats, titles, schemas
19:25 Server: room state machine, store, rate limiter
19:26 Socket layer, Fastify app, integration tests
19:44 Web app, Docker, Railway config, E2E tests
19:55 Fix: repeated throws were swallowed
20:11 Live on Railway
Nov 4, 11:16 – 12:38 Round history, analytics, canonical domain, security pass, privacy policy, SEO
Nov 5 Anonymous usage stats, rooms that survive redeploys

49 minutes from the first commit to a working production URL. The next morning went to the boring parts that make it a real site: domain behind Cloudflare, rate limits, a privacy policy, SEO. By lunch that was done too.

One honest caveat about those 49 minutes: the spec and the implementation plan were written before the first commit. More on that below, because it's the part that made everything else fast.

Where it ended up: 18 commits, about 5,500 lines of TypeScript, 1,700 lines of tests. 117 unit and integration tests, plus 9 Playwright specs that run on desktop and mobile viewports (18 runs).

Spec first, then code

I didn't start with "build me a planning poker app". I started with a conversation about what the app should do, and Claude turned it into a design spec: screens, the room lifecycle, what happens when the host disconnects, which numbers show up after a reveal, how the decks work, what's out of scope.

That last part turned out to be important. The spec has this line:

No Jira, no sounds, no character avatars, no mini-game.

Every one of those was an idea at some point. Writing them down as not happening kept the first version small.

From the spec Claude wrote an implementation plan: file structure, the order of work, and global constraints that every task had to respect. A few of them:

- No accounts, no DB, no PII persistence. Rooms in memory, deleted after 12 h idle. - Server never sends another participant's vote value before phase `revealed`. - Host offline > 60 s → host passes to earliest-joined online participant. - No `dangerouslySetInnerHTML`. 

Then it built the thing layer by layer: pure domain logic with tests, then the server, then the socket layer with integration tests, then the UI. Each layer was a commit I could read.

My job during all this was mostly reviewing diffs and making calls. Where the spec was silent, Claude asked instead of picking something and moving on, and most of those questions were about the product, not the code.

The architecture

Nothing exotic:

  • pnpm monorepo: shared (domain logic and zod schemas), server (Fastify 5 + Socket.IO), web (React 19, Vite, Tailwind 4, zustand).
  • Server-authoritative rooms in memory. Clients send intents ("vote 5", "reveal"), the server owns the state.
  • One Docker image serves the API, the socket and the static SPA on Railway.

The one design decision I'd point out: the server never broadcasts the room as-is. Each client gets its own projection, so a vote is invisible to everyone except its owner until the reveal:

export function projectRoom(room: Room, viewerId: string): RoomView { const revealed = room.phase === 'revealed'; return { // ... participants: [...room.participants.values()].map((p) => { const v = room.votes.get(p.id); return { id: p.id, name: p.name, hasVoted: !!v, vote: v && (revealed || p.id === viewerId) ? v.value : null, }; }), result: revealed ? room.result : null, }; } 

You can open DevTools during a round and there's nothing to peek at. It was one of the constraints in the plan, so every task after that had to respect it.

The security pass

On the second morning I asked Claude to review the app for security problems, the way an attacker would look at it. It came back with real findings in its own code:

  • Anyone could take someone else's seat. Participant ids are visible to everyone in the room, and joining with an id was enough to become that person, host included. The fix: each browser keeps a random secret key, and a seat belongs to the id and the key.
  • Rate limits were keyed by the wrong IP. Behind Railway's proxy and Cloudflare, the TCP peer is a proxy, not the user. Now the client IP comes from CF-Connecting-IP, but only when the request really came through Cloudflare. Otherwise anyone hitting the railway.app host directly could forge the header:
export function clientIp(headers: IncomingHttpHeaders, peer: string): string { const real = first(headers['x-real-ip']); if (real) { const viaCf = first(headers['cf-connecting-ip']); return isCloudflare(real) && viaCf && isIP(viaCf) ? viaCf : real; } // local and tests: last X-Forwarded-For hop, then the TCP peer } 
  • Room-code guessing, socket floods, 1 MB socket messages: each got a limit, and the limits have their own integration tests.
  • A strict CSP, with an E2E test that fails on any CSP violation in the console.

None of this is advanced. It's just the stuff I usually skip on a side project, and seeing it pointed out in my own code was more convincing than any checklist.

SEO for a SPA without SSR

A client-rendered React app sends the same empty index.html for every URL, so link previews in Slack and search snippets all look identical. I didn't want to add a framework just for that.

So the server renders only the <head>: it keeps the built index.html in memory and fills in the title, description, canonical URL, hreflang links for EN/RU, Open Graph tags and JSON-LD per route and language. Room pages get noindex and a generic title, so a private room name never leaks into a link preview. Unknown paths return a real 404 instead of a 200 with the app in it. robots.txt and sitemap.xml are generated too.

Google indexed both language versions of the home page within a day.

Rooms that survive a redeploy

Rooms live in memory, which is fine until you deploy. Then every running session loses its votes, history and chat. Hosts' browsers could already recreate their rooms after a restart, but only the name and settings.

The fix was a snapshot. The server writes all rooms to one JSON file on a Railway volume every 5 seconds and on SIGTERM, and loads it on start:

Slightly trimmed:

save(): boolean { const rooms = JSON.stringify(this.store.all().map((r) => r.toSnapshot())); if (rooms === this.last) return false; // nothing changed const tmp = `${this.path}.tmp`; writeFileSync(tmp, `{"v":1,"savedAt":${Date.now()},"rooms":${rooms}}`, { mode: 0o600 }); renameSync(tmp, this.path); // a crash mid-write never leaves half a file this.last = rooms; return true; } 

After a restart everyone comes back offline and reconnects on their own. If a countdown was running when the server stopped, the round is revealed on load.

One small Docker detail: Railway mounts volumes owned by root, and the app runs as an unprivileged user. Instead of running the server as root, the container starts as root only long enough to chown the snapshot directory, then drops privileges:

CMD ["sh", "-c", "if [ -n \"$SNAPSHOT_PATH\" ]; then d=$(dirname \"$SNAPSHOT_PATH\"); mkdir -p \"$d\" && chown node:node \"$d\"; fi; exec setpriv --reuid=node --regid=node --init-groups node server/dist/index.js"] 

I tested it on production: created a room with a revealed round, triggered a redeploy, rejoined. Votes, topic, history and chat were all there. The cost is a short gap during deploys, because with a volume attached Railway stops the old container before starting the new one.

Knowing whether anyone actually uses it

Cloudflare Web Analytics counts page views without cookies. But page views don't tell you whether a room had five people voting or one person clicking around.

So the server logs one line when a room is created and one when it closes, with counters only: people, votes, revealed rounds, chat messages, tomatoes thrown, active minutes. No names, no topics, no messages, no IPs. A small script pulls those lines from the Railway logs of every deployment and prints a report: rooms created, rooms actually used (2+ people and at least one revealed round), votes per day.

I run the E2E suite against production after deploys, and I didn't want it inflating the numbers. Playwright appends a marker to its user agent, and the server flags those rooms:

const withMarker = <T extends { userAgent: string }>(device: T): T => ({ ...device, userAgent: `${device.userAgent} guesstimato-e2e`, }); 

What I actually did

Looking back at the sessions, my part was:

  • Scope. What goes in, what doesn't, what "done" means.
  • Product calls in the places where the spec was silent.
  • Testing by hand. I found a couple of small bugs while clicking around. Claude fixed them in minutes.
  • Approvals. Claude stopped and asked before every production deploy, before touching DNS, and before creating anything that costs money. Good habit.

And there were things I had to catch. The generated Open Graph image had the tomato logo squeezed to nothing by flexbox. The fix was one line (flex-shrink: 0), but nobody would have noticed without looking at the PNG. AI-written code still needs someone who looks at the result.

Would I do it again?

For a small app with one person deciding, yes, without hesitation. It's absurdly fast, and the result has more tests and more security work than my side projects usually get.

I wouldn't try the same approach on a big legacy codebase at work. There's no spec to start from, and the context Claude needs is spread across ten years of decisions nobody wrote down.

Guesstimato is free, no sign-up, in English and Russian: guesstimato.com. A Jira integration might be the next feature. If your team does planning poker, try it at your next refinement and tell me what breaks.