/ at /de so Google indexes the German homepage as default.
Co-authored-by: Cursor <cursoragent@cursor.com>
Kugelstand
Mobile-first PWA zum Aufzeichnen von Pétanque- und Boßeln-Scores. Spiele und Turniere bekommen eine Unique URL und können ohne Login geteilt und gemeinsam bearbeitet werden.
Features
- Sportwahl: Pétanque oder Boßeln
- Pétanque: Einzelwertung oder Teamwertung (1–4 Spielende), Zielpunktzahl (Presets 11/13, frei 1–99)
- Boßeln (Standalone): zwei Mannschaften à max. 8 mit Nummern, Wertung einfach oder Punktspiel, laufende Schoet-Erfassung (+1), manuelles Beenden
- Aufnahmen-/Schoet-Verlauf, Undo und Rematch (Standalone-Spiele)
- Spielernamen nach Spielstart editierbar
- Turniere (nur Pétanque) als Serie von Spielen mit kumuliertem Scoreboard und Schnell-Paarungen
- Sprachen: Deutsch, Englisch, Französisch, Spanisch, Italienisch
- Selfhosting mit Docker Compose
- Live-Updates des Spielstands über Server-Sent Events (SSE)
- Optionale Lage-Messung per Foto beim Punkteintrag (Pétanque; Tippen; mit OpenRouter auch Auto-Erkennung)
- Ergebnis teilen (Text + Bild) für Messenger nach Spielende / Turnier-Scoreboard
- Separater Zuschauer-Link (nur lesen) neben dem Schreib-Link; QR-Codes zum Teilen
- Zuletzt geöffnete Spiele/Turniere lokal auf dem Gerät (ohne Account)
- Liberapay-Spendenbutton auf der Startseite
Entwicklung
Voraussetzungen: Node.js 22+, Docker
# Postgres starten
docker compose -f docker-compose.dev.yml up -d
# Abhängigkeiten & Migrationen
cp .env.example .env
npm install
npx prisma migrate dev
# Dev-Server
npm run dev
Tests der Domänenlogik (scoring, scoreboard, suggestPoints):
npm test
Produktion (Docker)
docker compose up -d --build
Die App lauscht auf Port 3000 (Host-Port über PORT in .env änderbar). Davor einen Reverse-Proxy (Caddy/Nginx/Traefik) mit TLS setzen. Postgres ist nur im Compose-Netz erreichbar (kein Host-Port).
Beim Container-Start führt der Entrypoint automatisch prisma migrate deploy aus und startet danach die App. Der App-Container prüft /api/health (inkl. DB).
Live-Updates laufen in-process (SSE). Deshalb einen App-Container betreiben (kein horizontales Scaling ohne zusätzlichen Pub/Sub). Proxy muss SSE/Streaming erlauben (kein Response-Buffering) und X-Forwarded-For setzen (für Rate-Limits).
Update auf dem Server
Im App-Verzeichnis (z. B. auf dem LXC):
./scripts/update.sh
Das Script macht git pull --ff-only, baut und startet Compose neu und räumt ungenutzte Docker-Images/Build-Caches auf. Voraussetzung: .env und docker-compose.yml liegen im Repo-Root.
Backup & Restore
./scripts/backup-db.sh
Erzeugt backups/kugelstand-<UTC-Zeitstempel>.sql.gz. Restore-Beispiel gibt das Script aus. Ideal täglich per Cron (z. B. 0 3 * * *).
CI/CD (Gitea Actions)
Push auf main/master läuft über .gitea/workflows/ci-cd.yml: Lint/Test/Build, danach Deploy per WireGuard → SSH → ./scripts/update.sh.
Runner auf Unraid (neben Gitea): Gitea führt Jobs nicht selbst aus – act_runner als zusätzlicher Docker-Container auf dem gleichen Unraid.
Auf Unraid (Beispielpfad /mnt/user/appdata/gitea-runner): config.yaml mit privileged Job-Containern + TUN, Container Gitea-Runner mit --privileged und --device /dev/net/tun, Data-Mount inkl. .runner (Registrierung). Neu anlegen ggf. via unraid-run.sh im Appdata-Ordner.
Hinweise Unraid:
- Job-Container nutzen Host-Netz, damit das LXC im LAN erreichbar ist (WireGuard im Job entfällt).
config.yaml:network: host. - Nach Änderungen in der Unraid-Docker-UI Privileged + Device
/dev/net/tun+CONFIG_FILE=/data/config.yamlbeibehalten. DEPLOY_SSH_KEYals Base64-One-Liner speichern (Gitea maskiert Multiline-Keys unzuverlässig).- Actions müssen in Gitea aktiv sein (
ENABLED = truebzw. Admin-UI).
Repository-Secrets (Settings → Secrets):
| Secret | Inhalt |
|---|---|
WG_CONF |
Optional: wg-quick-Config, nur wenn DEPLOY_HOST vom Runner aus nicht erreichbar ist |
DEPLOY_SSH_KEY |
Privater SSH-Key als eine Zeile Base64 (base64 -w0 deploy_ed25519) |
DEPLOY_HOST |
Erreichbare Adresse des LXC im WireGuard-Netz |
DEPLOY_USER |
SSH-User |
DEPLOY_PATH |
Absoluter Pfad zum App-Checkout auf dem LXC |
DEPLOY_SSH_PORT |
Optional, Default 22 |
DEPLOY_HEALTH_URL |
Optional öffentlicher Check, z. B. https://kugelstand.de/api/health (Deploy prüft zuerst per SSH localhost:$PORT; öffentliche URL folgt Redirects, schlägt sie fehl ist das nur ein Warning) |
Beispiel-WG_CONF (als Secret, eine Datei / Multiline):
[Interface]
PrivateKey = <CI-Peer-PrivateKey>
Address = 10.10.0.50/32
[Peer]
PublicKey = <Server-oder-LXC-PublicKey>
Endpoint = <öffentlicher-WG-Endpoint>:51820
AllowedIPs = 10.10.0.0/24
PersistentKeepalive = 25
Auf dem LXC muss der öffentliche Key dieses CI-Peers als WireGuard-Peer eingetragen sein; SSH-User braucht Rechte für git pull und docker compose im DEPLOY_PATH.
Teilen: Mitspielen vs. Zuschauen
| Link | Pfad | Rechte |
|---|---|---|
| Schreib-Link | /g/{id} bzw. /t/{id} |
Punkte eintragen, Setup, Rematch |
| Zuschauer-Link | /vg/{viewId} bzw. /vt/{viewId} |
Nur lesen + Live-Updates |
Der Zuschauer-Link enthält nicht die Schreib-ID. Über „Link teilen“ lassen sich beide Varianten (inkl. QR) erzeugen.
Umgebungsvariablen
Siehe .env.example. Docker Compose liest die Werte aus .env.
| Variable | Beschreibung |
|---|---|
POSTGRES_USER |
Postgres-Benutzer |
POSTGRES_PASSWORD |
Postgres-Passwort |
POSTGRES_DB |
Datenbankname |
DATABASE_URL |
Connection-String für lokales npm run dev |
PORT |
Host-Port für Docker (Container intern immer 3000) |
GIT_SHA |
Kurzer Git-SHA für Footer (update.sh schreibt .git-sha und --build-arg) |
OPENROUTER_API_KEY |
Optional: Vision-API für Auto-Erkennung von Boules |
OPENROUTER_MODEL |
Optional: Modell-ID (Default google/gemini-3.6-flash) |
OPENROUTER_SITE_URL |
Optional: Referer für OpenRouter-Rankings |
Foto-Messung
Beim Punkteintrag kann ein Vogelperspektiv-Foto (zentriert über dem Cochonnet) geladen werden. Die App markiert Kugeln nach aufsteigender Entfernung; Seiten lassen sich zuweisen und ein Punktvorschlag übernehmen. Ohne OPENROUTER_API_KEY funktioniert nur das manuelle Tippen. Mit Key wird das Bild zur Erkennung an OpenRouter gesendet (keine Speicherung in der App-Datenbank).
Analytics (Plausible)
Die App lädt das Tracker-Script von https://plausible.elpatron.me/js/script.js für die Domain kugelstand.de. Custom Events werden clientseitig über plausible() gesendet (src/lib/plausible.ts).
Damit die Goals im Dashboard erscheinen, müssen sie unter Website-Einstellungen → Goals als Custom Events angelegt werden (Namen exakt wie unten). Optional können Funnels z. B. Game Created → Game Started → Game Finished gebaut werden.
| Goal | Wann | Props |
|---|---|---|
Game Created |
Neues Spiel von der Startseite angelegt | sport: PETANQUE | BOSSELN |
Tournament Created |
Neues Turnier von der Startseite angelegt | — |
Game Started |
Spiel-Setup abgeschlossen (Modus + Spieler) | mode: SOLO | TEAM, sport: PETANQUE | BOSSELN, optional bosselnVariant: SIMPLE | PUNKTSPIEL |
Tournament Started |
Turnier-Setup abgeschlossen | — |
Tournament Game Created |
Neues Spiel innerhalb eines Turniers | mode: SOLO | TEAM |
Share Clicked |
Link geteilt (Web Share API oder Clipboard) | method: native | clipboard, context: game | tournament, kind: play | watch |
Result Shared |
Spiel- oder Turnierergebnis geteilt | method: native | clipboard, context: game | tournament, withImage: true | false |
Game Finished |
Spiel beendet (Zielpunktzahl bzw. manuell bei Boßeln) | mode: SOLO | TEAM, sport: PETANQUE | BOSSELN, optional bosselnVariant |
Language Switched |
Sprache gewechselt | from, to: de | en | fr | es | it |
Outbound KnorrLabs |
Klick auf KnorrLabs im Footer | — |
Hinweis: Events von localhost werden von Plausible oft verworfen (Bot-Filter). Zum Testen die Produktionsdomain oder eine in Plausible hinterlegte Staging-Domain nutzen.