ai newsDo., 5. Nov.
EilmeldungDon't Let Claude Review Claude's Code. Models That Think Alike Make the Same Mistakes

49 minutes from first commit to production: building a planning poker app with Claude Code

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 y

dev.toDonnerstag 13:20 · 05.11.7 Min. Lesezeit
Bild: dev.to

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.

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

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).

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`. Enter fullscreen mode Exit fullscreen mode 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 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, }; } Enter fullscreen mode Exit fullscreen mode 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.

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:

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 } Enter fullscreen mode Exit fullscreen mode 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.

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

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; } Enter fullscreen mode Exit fullscreen mode 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"] Enter fullscreen mode Exit fullscreen mode 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.

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`, }); Enter fullscreen mode Exit fullscreen mode What I actually did Looking back at the sessions, my part was:

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.

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.

Quelle: dev.to (dev.to). Original öffnen