State, Stimmen und Facilitator-Powers — Das Raum-Erlebnis (Teil 2/6)
In Teil 1 haben wir das Projekt aufgesetzt, die Domain-Typen definiert und unseren Tech-Stack gewählt. Jetzt kommt der spaßige Teil — das Raum-Erlebnis bauen. Einen Raum erstellen, beitreten, abstimmen, aufdecken und neue Runden starten. Das Zeug, das die App tatsächlich nützlich macht.
Das Herzstück von allem ist ein einzelner Zustand-Store. Lass uns reinschauen.
Warum Zustand statt Redux oder Context?
Ich habe Redux Toolkit, MobX, Jotai verwendet — die sind alle okay. Aber für dieses Projekt hat Zustand den Sweet Spot getroffen:
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const usePokerStore = create<PokerStore>()(
persist(
(set, get) => ({
// ... alles lebt hier
}),
{
name: "planning-poker:identity",
partialize: (state) => ({
playerId: state.playerId,
playerName: state.playerName,
playerEmoji: state.playerEmoji,
}),
},
),
);
Drei Dinge fallen auf:
persist-Middleware — aber nur für die Identität (Name, ID, Emoji). Der Room-State wird nie inlocalStoragepersistiert. Wenn du den Tab schließt, verlässt du den Raum. Das ist das korrekte Verhalten.- Keine Provider, keine Context-Wrapper. Einfach
usePokerStoreirgendwo importieren und aufrufen. Null Zeremonie. - Alles in einem Store. Lokaler State (Wer bin ich?), Remote-State (Raum-Daten) und Actions (abstimmen, aufdecken etc.) — alles an einem Ort. Bei dieser Größe wäre Store-Splitting Overengineering.
Der Player-Identity-Flow
Bevor du einen Raum erstellen oder beitreten kannst, brauchst du einen Namen und ein Emoji. Die Home-Seite handhabt das mit einem netten kleinen Emoji-Picker:
const [name, setName] = useState(playerName ?? "");
const [selectedEmoji, setSelectedEmoji] = useState(
playerEmoji ?? PLAYER_EMOJIS[0]
);
Beim ersten Besuch ist der Emoji-Picker geöffnet. Wähle dein Krafttier 🦊, tippe deinen Namen ein, und du bist bereit. Diese werden über Zustand’s Persist-Middleware sitzungsübergreifend in localStorage gespeichert — beim nächsten Besuch bist du bereits „du".
Einen Raum erstellen
Der Erstellungs-Flow ist überraschend dicht, für wie einfach er aussieht:
createRoom: async (name, deckType, facilitatorName, customCards) => {
const { nanoid } = await import("nanoid");
const playerId = existingId ?? nanoid(8);
const roomId = nanoid(8);
// 1. Den Facilitator-Player erstellen
const facilitator: Player = {
id: playerId,
name: facilitatorName,
role: "facilitator",
vote: null,
isOnline: true,
joinedAt: Date.now(),
emoji,
};
// 2. Den Raum bauen
const room: Room = {
id: roomId,
name,
deckType,
status: "voting",
players: { [playerId]: facilitator },
currentStory: null,
stories: [],
timerStart: null,
timerDuration: null,
createdAt: Date.now(),
facilitatorId: playerId,
};
// 3. Verschlüsselungsschlüssel generieren
const roomKeyB64 = generateKeyMaterial();
const cryptoKey = await importKeyMaterial(roomKeyB64);
setAdapterEncryptionKey(cryptoKey);
storeKeyInSession(roomId, roomKeyB64);
// 4. Persistieren und abonnieren
const adapter = getAdapter();
await adapter.connect();
await adapter.createRoom(room);
const unsub = adapter.subscribeToRoom(roomId, (updated) =>
get()._setRoom(updated)
);
},
Schritt 3 ist die Magie — ein 256-Bit-AES-GCM-Schlüssel wird direkt in deinem Browser generiert. Er verlässt ihn nie. Er berührt nie einen Server. Er wird im URL-Hash eingebettet, damit du ihn mit Teamkollegen teilen kannst. Mehr zur Kryptographie in Teil 3.
Einem Raum beitreten
Der Beitritts-Flow ist das Spiegelbild von Erstellen, aber mit einer wichtigen Sicherheitsprüfung:
joinRoom: async (roomId, keyB64?) => {
// Schlüssel auflösen: URL-Parameter → sessionStorage → Fehler
const resolvedKey = keyB64 ?? loadKeyFromSession(roomId);
if (resolvedKey) {
const cryptoKey = await importKeyMaterial(resolvedKey);
setAdapterEncryptionKey(cryptoKey);
storeKeyInSession(roomId, resolvedKey);
}
const room = await adapter.getRoom(roomId);
if (!room) throw new Error(`Raum "${roomId}" nicht gefunden`);
// Ein unbenutztes Emoji wählen
const usedEmojis = new Map(
Object.values(room.players).map((p) => [p.emoji, p.id])
);
let emoji = playerEmoji;
if (!emoji || usedEmojis.has(emoji)) {
emoji = PLAYER_EMOJIS.find((e) => !usedEmojis.has(e)) ?? PLAYER_EMOJIS[0];
}
// Bestehende Rolle und joinedAt bei Wiederverbindung beibehalten (Seiten-Refresh)
const existing = room.players[id];
const player: Player = {
id,
name: playerName,
role: existing?.role ?? "voter",
vote: existing?.vote ?? null,
isOnline: true,
joinedAt: existing?.joinedAt ?? Date.now(),
emoji,
};
await adapter.joinRoom(roomId, player);
},
Diese existing-Prüfung ist kritisch. Wenn ein Facilitator seinen Browser aktualisiert, sollte er immer noch der Facilitator sein, wenn er zurückkommt — nicht zum Voter degradiert werden. Das Gleiche gilt für Stimmen: Ein Refresh mitten in der Runde sollte deine bereits abgegebene Stimme nicht löschen.
Optimistische Updates — das Geheimnis der Schnelligkeit
Jede Benutzeraktion aktualisiert die UI sofort, dann schreibt sie zum Server:
submitVote: async (value) => {
const { room, playerId } = get();
if (!room || !playerId) return;
// UI aktualisiert sich sofort
set({
room: {
...room,
players: {
...room.players,
[playerId]: { ...room.players[playerId], vote: value },
},
},
});
// Server-Schreibvorgang passiert im Hintergrund
try {
await getAdapter().submitVote(room.id, playerId, value);
} catch (err) {
if (/command limit/i.test(err.message)) set({ error: err.message });
}
},
Dieses Muster — „lokal aktualisieren, dann remote synchronisieren" — lässt die App sich sofort anfühlen. Deine Karte erscheint selektiert in dem Moment, in dem du sie antippst. Die SSE-Subscription liefert dann den autoritativen Server-State, der das optimistische Update bestätigt oder korrigiert.
Jede Schreibaktion folgt diesem Muster: submitVote, revealVotes, resetVotes, setCurrentStory, setObserverMode, setTimer. Sechs Actions, sechs optimistische Updates.
Der Werkzeugkasten des Facilitators
Der Facilitator leitet die Show. Er bekommt ein schwebendes Kontrollpanel, verankert unten rechts am Bildschirm:
- Stimmen aufdecken — nur aktiviert, wenn mindestens eine Person abgestimmt hat. Deckt alle Karten gleichzeitig auf, archiviert die Runde mit einer Konsens-Schätzung und zeigt ein Recharts-Balkendiagramm mit der Stimmenverteilung.
- Neue Runde — setzt alle Stimmen zurück, löscht den Story-Titel und startet optional einen Countdown-Timer.
- Observer-Modus — schaltet zwischen „Ich stimme ab" und „Ich schaue nur zu" um. Wenn der Facilitator zum Observer wechselt, verschwindet seine Karte aus dem Abstimmungs-Grid.
- Timer-Presets —
30s,1m,2m,3m,5moderaus. Schnelles Tippen, keine Eingabe nötig.
Wie Aufdecken und Konsens funktionieren
Wenn der Facilitator auf Aufdecken drückt, steckt echte Logik dahinter:
// Einen numerischen Konsens nur veröffentlichen, wenn ≥80% der Voter die gleiche Karte gewählt haben
const tally: Record<string, number> = {};
voters.forEach((p) => {
tally[p.vote!] = (tally[p.vote!] ?? 0) + 1;
});
const [topVote, topCount] = Object.entries(tally)
.sort((a, b) => b[1] - a[1])[0];
const hasConsensus =
voters.length > 0 && topCount / voters.length >= 0.8;
const consensusEstimate = hasConsensus ? topVote : "?";
80%-Schwelle. Wenn vier von fünf Leuten „5" stimmen, ist der Konsens „5". Wenn es gestreut ist — „3", „5", „8", „13" — ist der Konsens „?" und das Team muss diskutieren.
Die Runde wird als Story-Objekt archiviert mit Titel, finaler Schätzung und einer Aufschlüsselung, wer was gestimmt hat. Das lebt in sessionStorage — das ist das Gedächtnis deiner Sitzung, und es ist weg, wenn du den Tab schließt.
Facilitator-Übergabe
Was passiert, wenn der Facilitator geht? Jemand muss übernehmen. Die Regel ist einfach: Der am längsten beigetretene Voter wird befördert.
if (facilitatorId === playerId) {
const remaining = Object.values(players)
.sort((a, b) => a.joinedAt - b.joinedAt);
const nextFacilitator = remaining.find((p) => p.role !== "observer")
?? remaining[0];
facilitatorId = nextFacilitator.id;
players[nextFacilitator.id] = {
...players[nextFacilitator.id],
role: "facilitator"
};
}
Observer werden deprioritisiert — wenn jemand nur zuschaut, will er wahrscheinlich nicht plötzlich die Leitung übernehmen. Aber wenn keine Voter übrig sind, wird ein Observer befördert. Irgendjemand muss die Lichter anlassen.
Session-History und das Ergebnis-Diagramm
Jede aufgedeckte Runde wird im Session-Sidebar gestapelt mit Story-Titel, Konsens-Schätzung und individueller Stimmaufschlüsselung:
Session · abc123
──────────────────
#1 AUTH-142 Login-Flow → 5
Alice 5 Bob 5 Carol 3
#2 AUTH-143 Passwort zurücksetzen → 3
Alice 3 Bob 3 Carol 3 🎉 Einstimmig!
Wenn Stimmen aufgedeckt werden, rendert die ResultsChart-Komponente ein Recharts-Balkendiagramm, das die Stimmenverteilung zeigt. Einstimmige Abstimmungen bekommen ein 🎉. Konsens-Stimmen (80%+) werden hervorgehoben. Und der Durchschnitt wird für numerische Decks berechnet — nützlich für T-Shirt-Sizing, wo „Was ist der Durchschnitt von S, M, L?" nicht ganz funktioniert.
Der Countdown-Timer
Das war eine der befriedigendsten Komponenten zum Bauen — ein kreisförmiger SVG-Ring, der herunterzählt:
const CIRC = 99.9; // Umfang bei r=15.9
const pct = duration ? (remaining / duration) * 100 : 0;
const dashOffset = CIRC - (pct / 100) * CIRC;
const isUrgent = remaining <= 10 && remaining > 0;
const isDone = remaining === 0;
Unter 10 Sekunden wird der Ring rot und pulsiert. Bei Null deckt er automatisch die Stimmen auf (wenn der Facilitator es nicht schon getan hat). Der onExpire-Callback feuert dank eines useRef-Flags genau einmal — keine doppelten Aufdeckungen.
Timer-Presets erscheinen nach dem Ende einer Runde: 30s, 1m, 2m, 3m, 5m oder aus. Ein Tipp, und die nächste Runde startet mit einer tickenden Uhr. Es fügt genau den richtigen Zeitdruck hinzu, um das Team fokussiert zu halten.
Was als Nächstes kommt
Wir haben den gesamten Abstimmungsablauf gebaut — Räume, Spieler, Stimmen, Aufdeckungen, Runden, Timer und Session-History. Der Store handhabt optimistische Updates, der Facilitator hat volle Kontrolle, und alles fühlt sich reaktionsschnell an.
Aber da ist der Elefant im Raum: Wie wird irgendetwas davon zwischen Browsern synchronisiert? Wie werden verschlüsselte Bytes zu Echtzeit-Updates? Das ist Teil 3 — Ende-zu-Ende-Verschlüsselung und die Echtzeit-Engine.
Weiter voran und genieße jeden Schritt deiner Coding-Reise.
