|

Eine Echtzeit-Planning-Poker-App bauen — Von Null zum Schätzen (Teil 1/6)

Sprint Planning. Du kennst das Ritual — das Team versammelt sich, jemand teilt ein Jira-Ticket, alle schätzen still, wie lange es dauern wird, und dann dreht ihr gleichzeitig eure Karten um. Mit etwas Glück gibt’s Konsens. Wenn nicht, eine gesunde Diskussion.

Das Problem? Die meisten Planning-Poker-Tools da draußen sind entweder klobige Enterprise-Software, bei der man sich registrieren, Plugins installieren und sein Erstgeborenes verkaufen muss — oder sie sind so simpel, dass sie nicht mal die Session-History speichern.

Also habe ich eins gebaut. Eine kostenlose, Echtzeit-, Ende-zu-Ende-verschlüsselte Planning-Poker-App. Keine Accounts. Keine Registrierung. Keine Kosten. Einfach die URL öffnen und loslegen. Lass mich dir erzählen, wie.

Was genau bauen wir?

Hier ist die Feature-Liste, bei der ich gelandet bin:

  • 🃏 Mehrere Kartendecks — Fibonacci, T-Shirt, Zweierpotenzen oder ein eigenes Custom-Deck
  • Echtzeit-Multiplayer — SSE-Streaming über Cloudflare Worker, sofortige Updates, kein Polling
  • 🔐 Ende-zu-Ende-verschlüsselt — AES-GCM-256. Der Schlüssel berührt nie einen Server
  • 👁 Anonyme Abstimmung — Stimmen bleiben verborgen, bis der Facilitator aufdeckt
  • 🎭 Rollen — Facilitator, Voter, Observer (nur zuschauen)
  • Countdown-Timer — deckt Stimmen automatisch auf, wenn er abläuft
  • 📜 Session-History — jede aufgedeckte Runde archiviert mit Schätzungen und Einzelstimmen
  • 🔗 Ein-Klick-Teilen — Verschlüsselungsschlüssel automatisch in der Share-URL eingebettet
  • 🌙 Dark Mode — weil natürlich

Der Tech-Stack

Jedes Tool verdient seinen Platz. Hier ist, was ich gewählt habe und warum:

EbeneTechnologieWarum
FrameworkReact 19 + TypeScriptWas ich am besten kenne. Typisierte Props, Hooks, null Überraschungen
BuildViteSofortiges HMR, saubere Config, schnelle Builds
StylingHeroUI v3 + Tailwind CSS v4Schöne Defaults, Dark Mode out of the box
StateZustandWinzig, schnell, kein Boilerplate — perfekt für diese Größe
RoutingReact Router v6Dateiartiges Routing mit useParams und useNavigate
ChartsRechartsDrop-in-Abstimmungsverteilungs-Charts
VerschlüsselungWeb Crypto APIBrowser-nativ, hardwarebeschleunigt
EchtzeitUpstash Redis + SSEPub/Sub-Streaming durch einen Cloudflare Worker
WorkerHono auf Cloudflare WorkersWinzig, schnell, tolle DX für Edge-First-APIs
TestsVitest + Testing LibrarySchnell, modern, für Vite gebaut

Das ist React 19 im Frontend, ein Cloudflare Worker als dünner Proxy in der Mitte und Upstash Redis als Persistenzschicht. Der Browser erledigt die schwere Arbeit — Verschlüsselung, State-Management, Geschäftslogik. Der Server schiebt nur verschlüsselte Bytes hin und her.

Projekt-Setup

Nichts Besonderes — Vites React-TypeScript-Template mit pnpm:

pnpm create vite@latest planning-poker -- --template react-ts
cd planning-poker
pnpm install

Die Projektstruktur, bei der ich gelandet bin:

src/
├── adapters/         # Upstash-REST-Kommunikation
├── components/
│   ├── AppIcon/      # SVG-Logo
│   ├── Layout/       # App-Shell + Dark-Mode-Toggle
│   ├── PlayerCard/   # Voter-Kachel
│   ├── Timer/        # Countdown-Ring mit Auto-Reveal
│   ├── VotingDeck/   # Kartenauswahl-Grid
│   └── ResultsChart/ # Stimmenverteilungs-Balkendiagramm
├── config/
│   └── decks.ts      # Eingebaute Deck-Definitionen + Timer-Presets
├── hooks/
│   └── usePokerStore.ts  # Zustand-Store — das Gehirn
├── pages/
│   ├── Landing.tsx   # Marketing-Seite
│   ├── Home.tsx      # Erstellen-/Beitreten-Formular
│   ├── Room.tsx      # Live-Raum
│   └── NotFound.tsx  # 404
├── types/
│   └── index.ts      # Room, Player, Story, RoomAdapter Interfaces
└── utils/
    ├── crypto.ts     # AES-GCM-256-Helfer
    └── logger.ts     # Feature-gesteuertes Logging

Jeder Ordner hat einen klaren Zweck. Komponenten sind fokussiert und wiederverwendbar. Das adapters/-Verzeichnis abstrahiert das Backend, sodass man theoretisch Upstash gegen alles austauschen könnte, was das RoomAdapter-Interface implementiert.

Die Domain-Typen definieren

Bevor ich eine einzige Komponente geschrieben habe, habe ich die Domain getypt. Das erspart dir das „Welche Form hat dieses Objekt nochmal?"-Debugging später:

export type CardValue = string;

export interface Player {
  id: string;
  name: string;
  role: "facilitator" | "voter" | "observer";
  vote: CardValue | null;
  isOnline: boolean;
  joinedAt: number;
  emoji?: string;
}

export interface Room {
  id: string;
  name: string;
  deckType: DeckType;
  customCards?: CardValue[];
  status: "voting" | "revealed" | "idle";
  players: Record<string, Player>;
  currentStory: Story | null;
  stories: Story[];
  timerStart: number | null;
  timerDuration: number | null;
  createdAt: number;
  facilitatorId: string;
  cmdCount?: number;
}

Ein paar Design-Entscheidungen, die erwähnenswert sind:

players ist ein Record<string, Player>, kein Array. O(1)-Lookups nach ID. Wenn du die Stimme eines Spielers in einem Raum mit 15 Leuten aktualisierst, willst du nicht iterieren.

facilitatorId ist ein separates Feld. Die Facilitator-Rolle wird auch am Player-Objekt gesetzt, aber ein Feld auf Room-Ebene macht die Prüfung „Bin ich der Facilitator?" zum Einzeiler statt zur Suche.

cmdCount für Rate-Limiting. Jeder Schreibvorgang in Redis erhöht diesen Zähler. Wenn er das Limit erreicht, wird der Raum read-only. Mehr dazu in Teil 3.

Das Deck-System

Ich wollte mehrere Schätzmethoden unterstützen, ohne es zu verkomplizieren. Hier ist die Deck-Config:

export const DECKS: Record<string, Deck> = {
  fibonacci: {
    id: "fibonacci",
    name: "Fibonacci",
    cards: ["1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"],
  },
  tshirt: {
    id: "tshirt",
    name: "T-Shirt",
    cards: ["XS", "S", "M", "L", "XL", "XXL", "?", "☕"],
  },
  powers2: {
    id: "powers2",
    name: "Powers of 2",
    cards: ["1", "2", "4", "8", "16", "32", "64", "?", "☕"],
  },
  custom: {
    id: "custom",
    name: "Custom",
    cards: ["1", "2", "3", "5", "8", "13", "?"],
  },
};

export const PLAYER_EMOJIS = [
  "🦊", "🐺", "🐻", "🐼", "🦁", "🐯", "🐨", "🐸",
  "🦉", "🐙", "🦋", "🦄", "🐲", "🦖", "🐳", "🦈",
  "🐝", "🦩", "🦚", "🦜", "🐬", "🦦", "🦥", "🐧",
];

export const TIMER_PRESETS = [30, 60, 120, 180, 300];

Jedes Deck enthält ? (weiß nicht) und (ich brauch eine Pause). Denn ehrlich gesagt sind das die wichtigsten Karten.

Die Emoji-Liste wird für Spieler-Avatare verwendet — wenn du einem Raum beitrittst, bekommst du ein einzigartiges Emoji, das noch nicht vergeben ist. Keine zwei 🦊 im selben Raum.

Was als Nächstes kommt

In diesem ersten Teil habe ich das Fundament gelegt — das Domain-Modell, die Projektstruktur und die Tech-Entscheidungen. Das Fundament ist absichtlich langweilig. Wenn es interessant wird (Echtzeit-Sync, Verschlüsselung, Facilitator-Übergabe), willst du, dass deine Typen und Architektur einfach funktionieren.

In Teil 2 bauen wir die eigentliche Raum-Experience — den Zustand-Store, den Abstimmungsablauf, die Session-History und wie die Facilitator-Steuerungen funktionieren.

Weiter voran und genieße jeden Schritt deiner Coding-Reise.