Skip to content

MVP-Plan — Schmetterpause

  • Stand: 2026-08-21
  • Zuschnitt: Variante A — "Ergebnisse landen in der App"
  • Voraussetzung: ADR-0001 bis ADR-0004, CLAUDE.md

Zielsetzung

Der MVP beantwortet genau eine Frage: Tragen Leute freiwillig Ergebnisse ein?

Alles, was diese Frage nicht beantwortet, gehört nicht in den MVP. Die Erfolgsmessung ist verhaltensbasiert, nicht featurebasiert.

Definition of Done

Der MVP gilt als erfolgreich, wenn über fünf aufeinanderfolgende Arbeitstage mindestens zehn Matches von mindestens fünf verschiedenen Spielern eingetragen wurden, ohne dass jemand daran erinnert wurde.

Wird das verfehlt, ist der nächste Schritt nicht "mehr Features", sondern die Ursachenanalyse: zu umständliche Eingabe, fehlender Anlass zu spielen, oder schlicht kein Interesse. Der Ligamodus (M2) ist die Antwort auf "fehlender Anlass" — aber nur, wenn die Messung das zeigt.

Abgelesen wird das mit task office:dod. Die Anwendung hat dafür keinen Bildschirm und bekommt auch keinen: es ist eine Zahl, die man einmal liest.

Scope

Enthalten

Bereich Umfang
Spieler Anlegen mit Anzeigename, Wiedererkennung über signiertes Cookie
Match Erfassen: zwei Spieler, Satzergebnisse, Matchmodus (Best-of-N, Punkte zum Sieg)
Bestätigung Gegner bestätigt oder widerspricht dem eingetragenen Ergebnis
TTR Berechnung nach Verbandsformel, Historie pro Spieler
Rangliste Sortiert nach TTR, mit Anzahl Spiele und Bilanz
Match-Historie Liste aller Matches mit allen Sätzen und der Wertung, neueste zuerst
QR-Eingabe QR-Code an der Platte öffnet direkt die Ergebniseingabe
Betrieb Docker Compose lauffähig, CI mit Lint/Build/Verify

Bewusst nicht enthalten

Liga, Turnier, Gruppen/KO, Platten-Buchung, Slots, Redis, SSE/Livescore, Wall-Display, OIDC/Codehub-Login, Passkeys, Doppel, Handicap, Webhooks, Kubernetes- und ACA-Deployment.

Kubernetes und ACA sind nicht im MVP-Scope, aber die Invarianten aus CLAUDE.md (ein Image, Env-Konfiguration, stateless) gelten trotzdem ab der ersten Zeile. Der Unterschied: Wir bauen die Manifeste noch nicht, verbauen uns den Weg dorthin aber auch nicht.

Datenmodell (MVP-Umfang)

players
  id            uuid pk
  display_name  text not null
  ttr           int not null default 1000
  created_at    timestamptz

identities                      -- vgl. ADR-0003, im MVP nur provider='local'
  provider      text
  subject       text
  player_id     uuid fk
  created_at    timestamptz
  pk (provider, subject)

matches
  id            uuid pk
  home_id       uuid fk players
  away_id       uuid fk players
  best_of       int not null            -- 1, 3, 5, 7
  points_to_win int not null default 11
  status        text not null           -- pending | confirmed | disputed
  reported_by   uuid fk players
  played_at     timestamptz
  confirmed_at  timestamptz

match_sets
  match_id      uuid fk matches
  set_no        int
  home_points   int
  away_points   int
  pk (match_id, set_no)

ttr_history
  id            uuid pk
  player_id     uuid fk players
  match_id      uuid fk matches
  ttr_before    int
  ttr_after     int
  created_at    timestamptz

ttr_history ist im MVP nicht optional. Ohne sie lässt sich eine falsche Berechnung später nicht nachvollziehen oder korrigieren, und Verlaufsgrafiken wären nicht nachträglich rekonstruierbar.

Arbeitspakete

AP1 — Gerüst

Go-Modul, Verzeichnisstruktur, templ- und HTMX-Setup, Postgres-Migrations (goose), Repository-Interfaces, /healthz und /readyz, Runtime-Image (distroless), compose.yaml mit App und Postgres, Taskfile, Dagger-Pipeline.

Das Image entstand zunächst aus einem Dockerfile. Das ist entfallen, weil die Pipeline das Image ohnehin selbst zusammensetzt und zwei Definitionen desselben Images eine zu viel sind — Compose zieht heute das veröffentlichte Artefakt.

Fertig, wenn: task up startet die Anwendung, task ci läuft lokal grün durch und liefert dasselbe Ergebnis wie in der Pipeline.

AP2 — Spieler und Session

Anlage mit Anzeigename, signiertes Cookie, identities-Eintrag mit provider='local', Spielerliste. Kein Passwort.

Fertig, wenn: Ein Browser wird über Neustarts hinweg demselben Spieler zugeordnet.

AP3 — TTR-Package

Eigenes Package ohne DB- und HTTP-Abhängigkeit. Siegwahrscheinlichkeit, Änderungskonstante, veranstaltungsweise Wertung (Summe der Erwartungswerte über mehrere Einzel), kaufmännische Rundung.

Fertig, wenn: Tests gegen mindestens fünf von Hand nachgerechnete Fälle grün sind, inklusive eines Falls mit mehreren Matches in einer Wertung.

Dieses Paket zuerst bauen. Es ist reine Fachlogik, komplett testbar und unabhängig vom Rest — der ideale Kandidat für eine saubere erste Iteration.

AP4 — Matcherfassung

Formular: Gegner wählen, Matchmodus wählen, Sätze eintragen. Validierung (Satzergebnisse müssen zum Modus passen, Zwei-Punkte-Abstand, Anzahl Gewinnsätze). Status pending.

Fertig, wenn: Ein unplausibles Ergebnis abgelehnt wird und die Fehlermeldung sagt, warum.

AP5 — Bestätigung

Der Gegner sieht offene Ergebnisse und bestätigt oder widerspricht. Erst bei confirmed wird TTR gerechnet und ttr_history geschrieben. disputed blockiert die Wertung; wer widerspricht, bekommt direkt das Eingabeformular mit dem gemeldeten Ergebnis vorbefüllt und trägt ein, wie es wirklich ausgegangen ist — das Match geht damit als pending an den anderen zurück. Korrigieren darf jeder der beiden.

Fertig, wenn: Ein unbestätigtes Match die Rangliste nicht beeinflusst.

Kiosk. Für einen Turnierabend, an dem ein Rechner an der Platte steht, gibt es /kiosk: dort legt eine Person Spieler an und trägt Ergebnisse zwischen beliebigen zwei Spielern ein, die sofort gewertet werden. Wer zugesehen und mitgeschrieben hat, ist die Bestätigung — es gibt niemanden mehr zu fragen. Der Kiosk existiert nur, wenn SP_KIOSK_TOKEN gesetzt ist.

Was dort entsteht, ist nicht die Messung der Definition of Done: die fragt, ob Leute freiwillig eintragen, und ein Schriftführer am Turnierabend ist das Gegenteil davon. Kiosk-Spieler haben außerdem keine Identität und können sich später nicht vom eigenen Handy anmelden.

AP6 — Rangliste und Spielerprofil

Rangliste mit TTR, Spielen, Bilanz. Profilseite mit letzten Matches und TTR-Verlauf.

AP7 — QR-Eingabe

Druckbarer Aushang unter /qr, dessen QR-Code direkt in die Ergebniserfassung springt.

Fertig, wenn: Vom Scannen bis zum abgeschickten Ergebnis sind es höchstens drei Interaktionen.

Es gibt nur eine Platte. Damit entfällt der ursprünglich vorgesehene Code pro Platte samt Vorbelegung — es gibt nichts vorzubelegen, keine Kennung in der URL und keine tables-Tabelle, die das Slot-Modell eines späteren Meilensteins vorwegnehmen würde. Kommt eine zweite Platte dazu, ist das eine eigene Entscheidung mit eigenem Schemaschritt.

Der Code wird im Binary erzeugt, nicht als Bilddatei mitgeliefert. Ein QR-Code enthält eine absolute URL; eine mitgelieferte Datei müsste einen Host einbacken und würde Invariante 2 verletzen. Die Adresse stammt deshalb aus dem Request — SP_PUBLIC_BASE_URL überschreibt sie dort, wo ein Proxy davorsteht und der Request die öffentliche Adresse nicht mehr kennt.

Das Ziel ist der Anker #match auf der Startseite, nicht eine zweite Seite mit demselben Formular. Wer noch nicht erkannt ist, landet oben bei der Namenseingabe — genau die richtige Reihenfolge beim ersten Scan.

Reihenfolge

AP1 → AP3 → AP2 → AP4 → AP5 → AP6 → AP7

AP3 vor AP2, weil die Fachlogik unabhängig ist und einen frühen, gut testbaren Erfolg liefert. AP7 zuletzt, weil es die anderen Pakete voraussetzt — aber es darf nicht entfallen: Ohne QR-Code ist die Eingabehürde der wahrscheinlichste Grund, an der Definition of Done zu scheitern.

Build und CI/CD

Aufgabenteilung

Task (Taskfile.yml) ist die Einstiegsschicht für Menschen. Jeder Befehl, den ein Entwickler tippt, ist ein Task. Tasks enthalten keine Build-Logik, sondern rufen Dagger oder lokale Werkzeuge auf.

Dagger enthält die eigentliche Pipeline-Logik in Go. Sie läuft lokal und in der CI identisch — dieselbe Funktion, derselbe Container, dasselbe Ergebnis. Das ist der Grund für Dagger: kein separates CI-YAML, das nur auf dem Server läuft und nur dort kaputtgeht.

Tasks

Task Zweck
task up / task down Compose-Umgebung starten und stoppen
task generate templ-Templates und Query-Code generieren
task migrate Migrations gegen die lokale DB anwenden
task test Unit- und Repository-Tests, einzeln aufrufbar
task lint Dagger-Lint
task build Dagger-Build, erzeugt Binary und Image
task verify Dagger-Verify: End-to-End gegen das gebaute Image
task ci lint + test + build + verify, wie in der Pipeline

Dagger-Funktionen

lintgolangci-lint, templ fmt --check, go vet, Prüfung auf uncommittete Änderungen nach go generate (fängt vergessene Regenerierung von templ-Dateien).

build — Cross-Build des statischen Binaries, Bau des Container-Images, Ausgabe als OCI-Artefakt. Version aus Git-Tag bzw. Commit-SHA.

verify — Startet Postgres als Dagger-Service, wendet Migrations an, fährt das gebaute Image hoch, prüft /healthz und fährt einen End-to-End-Pfad durch: zwei Spieler anlegen, Match eintragen, bestätigen, Rangliste prüfen.

Der Verify-Schritt ist der eigentliche Wert der Dagger-Entscheidung: Er testet das gebaute Image, nicht den Quellcode. Damit fallen Fehler auf, die ein go test nie sieht — fehlende Migrations im Image, kaputte Env-Defaults, Templates, die im Container nicht eingebettet sind.

Reihenfolge in task ci

lint  →  test  →  build  →  verify

Die beiden quellcodenahen Schritte laufen zuerst, weil sie die billigen sind: ein fehlgeschlagener Unit-Test soll nicht erst auf einen Image-Bau warten. Verify hängt vom Build-Artefakt ab, nicht vom Quellcode, und steht deshalb am Ende. Wenn ein Schritt scheitert, brechen die folgenden ab.

test und verify messen nicht dasselbe. Verify fährt genau einen Pfad durch das gebaute Image und sieht nur, was dieser Pfad berührt. Die Tests erreichen die Fälle, durch die sich ein Browser nicht sinnvoll führen lässt — jede einzelne Ablehnungsart bei der Ergebniseingabe, ein Rollback, eine Wertung, die sich um null bewegt.

Offene Punkte

Bewusst noch nicht entschieden — gehören in Issues, nicht in ADRs:

  • Derzeit keine. Der letzte Punkt hier war der Startwert für neue Spieler, und er ist entschieden.

Entschieden, seit dieser Abschnitt geschrieben wurde: points_to_win ist konfigurierbar (11 als Vorgabe, 21 als Option), die Images liegen auf ttl.sh zum Herumzeigen und auf ghcr.io als Artefakt, und disputed löst sich über die Korrektur in AP5 auf.

Der Startwert bleibt 1000 (Issue #17), entschieden an der ersten Woche echter Ergebnisse statt an einer Simulation. Über 54 bestätigte Spiele liegt bei jedem Spieler die Zahl der Siege im Rahmen von etwa einer Standardabweichung um das, was seine Wertung vorhergesagt hat. Das Feld sieht flach aus — 81 Punkte von oben nach unten —, aber nicht weil die Wertung hinterherhinkt: der Beste gewinnt 67 % seiner Spiele, und 67 % sind bei Divisor 150 genau ein Abstand von 46 Punkten. Ein flaches Feld ist hier die richtige Antwort auf eine ausgeglichene Gruppe.