Demo·Alle Daten sind fiktiv und werden täglich zurückgesetzt.

Live-Demo · keine Anmeldung nötig

Coaching, das nicht in Tabellen verloren geht

Progressive Web App für Trainer und Athleten: Wochenpläne, Trainingslogs, Ernährungstagebuch und Feedback in Echtzeit — mobil zuerst, produktiv im Einsatz.

Die Demo startet mit fertigen Beispieldaten und wird täglich zurückgesetzt.

Coach-Übersicht
Athleten-Woche
Das Problem

Tabellen und Sprachnachrichten skalieren nicht

Die Betreuung lief über Google Sheets für die Pläne und WhatsApp für alles andere. Das funktioniert bei zwei Athleten. Bei zwanzig verliert der Trainer den Überblick — und der Athlet die Rückmeldung.

  • Pläne in Tabellen, die auf dem Handy unlesbar sind
  • Feedback verstreut über Chatverläufe, ohne Bezug zur Übung
  • Fortschritt nur sichtbar für den, der die Zahlen im Kopf hat
Funktionen

Was Trainer und Athleten täglich nutzen

Wochenplanung

Wochenplanung

Der Coach baut Trainingswochen mit Sätzen, Wiederholungen und Ziel-RPE. Der Athlet sieht sie mobil, Tag für Tag.

Feedback pro Übung

Feedback pro Übung

Ein Kommentarverlauf an jeder geplanten Übung statt im Chat. Beide Seiten sehen sofort, wer am Zug ist.

Ernährung & Gewicht

Ernährung & Gewicht

Tagebuch, Gewichtsverlauf und eine Energiebilanz-Schätzung, die Wasserschwankungen aushält.

Technik

Drei Entscheidungen, die das Projekt tragen

Jede davon war eine bewusste Wahl gegen eine naheliegendere Lösung. Der Code darunter stammt unverändert aus dem Repository.

Echtzeit

SSE über Postgres LISTEN/NOTIFY

Benachrichtigungen erreichen offene Tabs sofort — ohne Redis, ohne externen Dienst, ohne Polling. Die Datenbank, die ohnehin läuft, übernimmt das Pub/Sub.

Warum so?

Polling hätte bei jedem Client dauerhaft Last erzeugt, ein externer Broker wäre ein zusätzlicher Dienst mit eigenen Kosten und Ausfallmodi gewesen. Postgres kann LISTEN/NOTIFY nativ. Eine dedizierte Verbindung pro Node-Prozess hört auf den Kanal und verteilt die Events an alle verbundenen SSE-Streams. Das Muster ist damit bereits horizontal skalierbar, obwohl aktuell nur ein Knoten läuft.

lib/notificationBroker.ts
// One persistent LISTEN socket per Node process —// sql.listen() uses a dedicated connection, not the pool.await sql.listen(CHANNEL, (payload) => {    const parsed = JSON.parse(payload) as StreamEvent    if (parsed.userId) publish(parsed.userId, parsed)}) export async function notifyUser(event: StreamEvent) {    await sql.notify(CHANNEL, JSON.stringify(event))}
Statistik

Theil-Sen statt kleinster Quadrate

Die Energiebilanz wird aus dem Gewichtstrend geschätzt. Ein einzelner Wassertag nach einem Refeed darf dieses Ergebnis nicht kippen.

Warum so?

Kleinste Quadrate gewichten Ausreißer quadratisch — ein Sprung von +1,2 kg nach einem kohlenhydratreichen Tag verschiebt die geschätzte Kalorienbilanz um mehrere hundert Kilokalorien. Theil-Sen nimmt stattdessen den Median aller paarweisen Steigungen: ein Ausreißer berührt nur N−1 der N(N−1)/2 Paare und verschwindet im Median. Auf den Demo-Daten liegt die Schätzung 0,001 kg/Woche neben dem tatsächlich erzeugten Trend — trotz zweier eingebauter Wasser-Spikes.

lib/nutrition.ts
// Median of all pairwise slopes. A single water-weight// spike only touches N-1 of the N(N-1)/2 pairs, so its// leverage on the trend is negligible.export function theilSenSlope(xs: number[], ys: number[]) {    const slopes: number[] = []    for (let i = 0; i < xs.length; i++) {        for (let j = i + 1; j < xs.length; j++) {            const dx = xs[j]! - xs[i]!            if (dx === 0) continue            slopes.push((ys[j]! - ys[i]!) / dx)        }    }    slopes.sort((a, b) => a - b)    return median(slopes)}
Typsicherheit

Vom Schema bis in den Client

Drizzle leitet die Typen aus dem Datenbankschema ab, Server Actions geben typisierte Ergebnisse zurück statt zu werfen. Jede Action prüft ihre Berechtigung selbst.

Warum so?

Middleware ist keine Sicherheitsgrenze — CVE-2025-29927 erlaubt es, sie zu umgehen. Deshalb beginnt jede Server Action mit einer eigenen auth()-Prüfung, statt sich auf den Router zu verlassen. Fehler werden als typisiertes ActionResult zurückgegeben, damit der Client sie mit useActionState behandeln kann, statt sie als unbehandelte Exception im Log zu finden. Mehrschrittige Schreibvorgänge laufen in einer Transaktion.

app/(coach)/admin/actions.ts
export async function updateExercise(    id: string, name: string,): Promise<ActionResult> {    // Middleware is not a security boundary (CVE-2025-29927) —    // every Server Action verifies auth independently.    const session = await auth()    if (!session?.user?.id)        return { success: false, error: "Not authenticated" }    if (session.user.role !== "coach")        return { success: false, error: "Not authorized" }     await db.transaction(async (tx) => { /* ... */ })    return { success: true }}
Architektur

Wie die Teile zusammenhängen

Ein Monorepo, ein Deployment, keine Microservices. Die Datenbank übernimmt neben der Persistenz auch das Pub/Sub für Echtzeit-Events — das spart einen ganzen Dienst.

Client
PWA · React 19Service WorkerWeb PushOffline-Shell
Edge
CaddyTLS automatischSSE-Passthrough
Anwendung · Next.js 16
Server ComponentsServer ActionsSSE-EndpointnotificationBrokerNextAuth · OAuth
Daten
PostgreSQL 16Drizzle ORMLISTEN / NOTIFY
Betrieb
Docker ComposeHetzner VPSGitHub ActionsMigrationen beim Deploy
Qualitätssicherung

Getestet, gebaut und ausgeliefert

15
E2E-Specs mit Playwright
3
Geräteprofile: Desktop, Mobile, Tablet
17
Unit-Test-Dateien mit Vitest
3
CI-Stufen: Build, E2E, Deploy
TypeScript strictDockerGitHub ActionsVitestPlaywrightPWA · Web Push

Am besten selbst ausprobieren

Die Demo läuft mit vollständigen Beispieldaten: sechs Trainingswochen, 140 Tage Gewichts- und Ernährungsverlauf, offene Feedback-Threads.

Eigenentwicklung · produktiv im Einsatz

Next.js 16 · React 19 · TypeScript · Drizzle · PostgreSQL