Automatisch generiertes Vereinsportal für die Fibalon Baskets Neumarkt. Ruft alle 6 Stunden Spielplandaten von der Basketball-Bund-API ab und veröffentlicht eine statische Website mit Spielplänen, Tabellen, Turnierbäumen, Team-Seiten und abonnierbaren Kalender-Feeds.
https://olieder.github.io/bbb-ics-generator/
- Bund-Startseite (
generated/index.html) mit allen Bundesländern eingebundener Vereine, ergänzt um den Block „Bundesweite Wettbewerbe“ für Vereine, deren Teams nur in bundesweiten Ligen spielen - Land-Seiten (
generated/<bundesland>/index.html) mit den Ligatabellen der eingebundenen Vereine; jede Liga erscheint nur einmal (dedupliziert über dieligaId), Tabellenzeilen eingebundener Vereine sind hervorgehoben und verlinkt. Mit WAM-Cache sind die Ligen nach Verbands-, Bezirks- und Kreisebene gruppiert - Migrationsbanner auf der Bund-Seite, wenn mindestens ein Verein
legacyRootOutput: truegesetzt hat - Portal-Rechtsseiten (Impressum, Datenschutz, Barrierefreiheit) des Portal-Betreibers unter
generated/
- Übersicht aller Teams mit Teaser-Karten
- Teaser zeigt adaptiv: bei Saisonbeginn zukünftige Spieltermine, bei laufender Saison letzte Ergebnisse + nächstes Spiel, am Saisonende Hinweis auf Saisonende
- Streak-Anzeige (z.B. "Serie: 3 Siege")
- Navigation mit Hamburger-Menü, sticky Header
- Next-Game Teaser — nächstes Spiel mit Datum, Uhrzeit, Gegner, Heim/Auswärts, Hallenname, Adresse, Leaflet-Karte und Navigationslinks zu Google Maps + Apple Maps
- Tabelle — offizielle Tabelle + Games-Behind-Variante (NBA-Logik)
- Turnierklammer — für Pokalwettbewerbe mit Vorschau zukünftiger Runden; Ligen (auch "Bezirksklasse"/"Kreisklasse" ohne "liga" im Namen) werden als Tabelle dargestellt, die Wettbewerbsart wird per Namensregel und
crossTableExistsder API erkannt (ADR-028) - Spielplan — alle/Heim/Auswärts-Tabs mit ICS-Kalender-Links
- Archiv — abgelaufene Saisons werden automatisch archiviert und bleiben als eigener Tab mit Endtabelle und Ergebnissen einsehbar. Das Archiv ist pro Verein getrennt (
generated/archive/<clubId>/<saison>.json, nach der stabilenclubIdstatt nach Bundesland, damit es einen Bundesland-Wechsel des Vereins übersteht) und wird vondeploy.ymlnach jedem Lauf nachmaincommittet, weil die API alte Saisons irgendwann nicht mehr liefert. Ist ein Team gerade nicht in den aktuellen Daten des Vereins gelistet (z.B. zwischen zwei Saisons), bleibt seine Teamseite mit einem kurzen Hinweis erhalten, statt zu verschwinden (Grundlage ist diemetadata.jsondes vorherigen Laufs im Vereinsverzeichnis).
Jedes Team bietet drei ICS-Feeds:
| Plattform | Methode |
|---|---|
| iOS / macOS | iOS/Mac-Button → öffnet direkt in Kalender.app |
| Android | Android-Button → Google Calendar Abonnement-Dialog |
| Andere | ICS Download → Datei manuell importieren |
Varianten: Alle Spiele, Nur Heimspiele, Nur Auswärtsspiele
flowchart TD
API["Basketball-Bund REST-API"] --> CU["cronUpdate.js\nOrchestrator"]
CU --> AC["apiClient.js\nAPI-Facade"]
CU --> IG["icsGenerator.js\nRFC 5545 Renderer"]
CU --> ST["storage.js\nPersistenz"]
ST --> GEN["generated/{bundesland}/{club}/\nICS-Dateien + metadata.json"]
CU --> GH["generateHTML.js\nHTML-Generator"]
GH --> HTML["generated/{bundesland}/{club}/\nindex.html + teams/*.html"]
CU --> AP["aggregatePages.js\nBund-/Land-Seiten"]
PJ["portal.json"] --> AP
WAM["data/wam-ligen-cache.json"] --> AP
AP --> PORTAL["generated/\nindex.html + {bundesland}/index.html"]
WR["wam-refresh.yml\nquartalsweise"] --> WAM
HTML --> GP["GitHub Pages\nolieder.github.io/bbb-ics-generator/"]
PORTAL --> GP
bbb-ics-generator/
├── src/
│ ├── server.js # Express-Server (lokale Entwicklung)
│ ├── cronUpdate.js # Multi-Club-Orchestrator (API → ICS + metadata.json + HTML, pro Club; danach Aggregation)
│ ├── aggregatePages.js # Bund-/Land-Seiten und Portal-Legal-Seiten aus den Club-Ergebnissen
│ ├── wamClient.js # WAM-Liga-Abruf (paginiert)
│ ├── wamCache.js # Laden/TTL/Index/Refresh des WAM-Liga-Caches
│ ├── portalConfig.js # Lädt und validiert portal.json (Betreiberangaben)
│ ├── clubs.js # Lädt clubs/<bundesland>/<club>/config.json rekursiv
│ ├── verbandMapping.js # verbandId → Bundesland-Zuordnung + Mehrheitsvotum pro Club
│ ├── apiClient.js # Basketball-Bund API-Client (inkl. mapWithConcurrency-Helfer)
│ ├── icsGenerator.js # ICS-Datei-Generierung (RFC 5545)
│ ├── storage.js # Datei-I/O, Teams-Cache, Slug-Validierung (Path-Traversal-Schutz)
│ ├── seasonArchive.js # Saison-Archiv pro Verein (generated/archive/<clubId>/)
│ └── generateHTML.js # Statischer HTML-Generator (pro Club aufgerufen)
├── scripts/
│ └── refresh-wam-cache.js # CLI: aktualisiert data/wam-ligen-cache.json (npm run wam:refresh)
├── data/
│ └── wam-ligen-cache.json # Versionierter WAM-Liga-Cache (quartalsweise aktualisiert)
├── portal.example.json # Vorlage für portal.json (Betreiberangaben des Portals)
├── portal.private.example.json # Vorlage für den privaten Modus (ohne Impressum)
├── clubs/ # Ein Verzeichnis pro Bundesland/Club
│ └── bayern/
│ └── fibalon/
│ └── config.json # Vereinskonfiguration (clubId, Theme, legal, legacyRootOutput)
├── generated/ # Ausgabeverzeichnis (von GitHub Actions befüllt)
│ ├── index.html # Bund-Startseite
│ ├── {impressum,datenschutz,barrierefreiheit}.html # Portal-Rechtsseiten
│ ├── archive/ # Saison-Archiv, versioniert (einzige Ausnahme in .gitignore)
│ │ └── {clubId}/
│ │ └── {seasonId}.json # Archivierte Saison eines Vereins (alle seine Teams)
│ ├── bayern/
│ │ ├── index.html # Land-Seite (Ligatabellen der eingebundenen Vereine)
│ │ └── fibalon/
│ │ ├── index.html # Startseite mit Team-Teasern
│ │ ├── metadata.json # Team-Metadaten, Spielplandaten, Tabellen
│ │ ├── teams/ # Individuelle Team-Seiten
│ │ │ └── {teamId}.html
│ │ └── {teamId}_{type}.ics
│ └── {teamId}_{type}.ics # Alt-Pfad-Duplikat (nur ICS) für Clubs mit legacyRootOutput: true (siehe ADR-016/ADR-023 in docs/arc42)
├── tests/
│ └── e2e/ # End-to-End Tests (node:test)
└── .github/workflows/ # GitHub Actions (automatisches Update alle 6h)
Jeder Verein bekommt ein eigenes Verzeichnis unter clubs/<bundesland-slug>/<club-slug>/config.json. Der Ordnername (<bundesland-slug>) ist dabei nur eine Organisationshilfe im Repository — welches Bundesland tatsächlich für die Ausgabe-URL verwendet wird, ermittelt cronUpdate.js bei jedem Lauf automatisch aus den echten Liga-Daten der Teams (siehe src/verbandMapping.js).
Beispiel (illustrativ, angelehnt an clubs/bayern/fibalon/config.json):
{
"clubId": "4468",
"legacyRootOutput": false,
"theme": {
"primary": "#004174",
"accent": "#009ef3",
"logoUrl": "https://example.com/logo.png"
},
"cupColor": "#7c3aed",
"legal": {
"operator": "Musterverein Beispielstadt e.V.",
"address": "Musterstraße 1, 92318 Beispielstadt",
"email": "",
"phone": "",
"responsible": ""
},
"onboarding": {
"status": "pending"
}
}clubId(Pflichtfeld) — die Basketball-Bund-Vereins-ID.theme(optional) —primary/accent/logoUrlüberschreiben das Standard-Theme. Wichtig: Der Schlüssel muss exaktthemeheißen —cronUpdate.jsliestclub.config.theme; ein anderer Schlüsselname (z.B._theme_example) wird stillschweigend ignoriert und das Standard-Theme greift.cupColor(optional) — Akzentfarbe für Pokalwettbewerbe.name(optional) — Anzeigename des Vereins auf den Portal-Seiten (Bund-/Land-Seiten); Fallback istlegal.operator, dann der Ordnername.legal(optional) — steuert Footer-Links und rechtliche Pflichtseiten (siehe unten).legacyRootOutput(optional,true/false) — nur für Vereine, die bereits vor dem Multi-Club-Umbau unter dem alten, flachen Pfad (generated/{teamId}_{type}.ics) liefen und bestehende Kalender-Abos haben. Erzeugt zusätzlich zur neuen, verschachtelten Ausgabe ein ICS-Duplikat am alten Pfad, inklusive Migrationshinweis im Kalender; die Bund-Seite zeigt dann einen Migrationsbanner. Neue Vereine setzen dieses Feld nicht.onboarding(optional, informativ) —status-Feld zur manuellen Nachverfolgung des Onboarding-Fortschritts eines Clubs, aktuell die Werte"pending"oder"confirmed". Wird von keinem Modul ausgewertet (kein Code liest dieses Feld) — reine Organisationshilfe für die Betreiber, vergleichbar mit dem Bundesland-Ordnernamen im Quellbaum.
Das legal-Objekt steuert Footer-Links und rechtliche Pflichtseiten:
- Alle Felder sind optionale Strings.
datenschutz.htmlundbarrierefreiheit.htmlwerden immer generiert.impressum.htmlund der Impressum-Footer-Link werden nur generiert, wenn mindestens ein Feld inlegalnicht leer ist.- Ist
legalvollständig absent oder alle Felder leer, entfällt der Impressum-Link im Footer.
Teams werden automatisch über die Basketball-Bund API ermittelt und für 30 Tage gecacht. Der Cache erneuert sich nach 30 Tagen automatisch.
Die Bund-/Land-Seiten und die Portal-Rechtsseiten brauchen die Angaben des Portal-Betreibers. Die Datei portal.json im Repo-Root wird vom Betreiber selbst angelegt. Es gibt zwei Betriebsarten:
Strenger Modus (Standard, mit Impressum): portal.example.json nach portal.json kopieren und ausfüllen.
operator,address,email— Pflichtfelderphone,responsible— optional
{ "operator": "Vorname Nachname", "address": "Straße 1, 12345 Ort", "email": "kontakt@example.de" }Privater Modus (ohne Impressum, Opt-in): portal.private.example.json als Vorlage. Nur der Boolean "private": true aktiviert den Modus. Pflicht ist nur operator (Name); optional contactUrl (muss mit https:// beginnen). address, email, phone, responsible werden ignoriert und nicht veröffentlicht. Es entsteht kein impressum.html; der Footer zeigt (falls gesetzt) einen Kontakt-Link. Das ist eine Entscheidung des Betreibers, keine Rechtsberatung (siehe ADR-027). Wechsel zurück zum strengen Modus: address/email eintragen und private entfernen. Club-Seiten behalten ihre eigenen Impressen.
{ "private": true, "operator": "Vorname Nachname", "contactUrl": "https://github.com/<user>/<repo>/issues" }Deploy-Voraussetzung: portal.json (mit den Betreiberangaben) muss im Repo-Root liegen, bevor nach main gemergt wird; sonst deployt deploy.yml ohne Bund-/Land-Seiten und der Job wird rot.
Ohne gültige portal.json (Datei fehlt, Pflichtfeld leer, ungültige contactUrl) werden keine Bund-/Land-Seiten erzeugt und der Update-Lauf endet mit Fehler; die Ausgabe der einzelnen Vereine und die ICS-Dateien bleiben davon unberührt. Über die Umgebungsvariable BBB_PORTAL_CONFIG kann ein anderer Pfad angegeben werden.
npm run wam:refresh aktualisiert data/wam-ligen-cache.json für alle Bundesländer der Vereine unter clubs/ (abgeleitet aus dem Ordnernamen clubs/<bundesland>/…; bundesweit wird übersprungen). Der Cache liefert die Ebenen-Zuordnung (Verband/Bezirk/Kreis) für die Gruppierung der Land-Seiten. Der Workflow wam-refresh.yml läuft quartalsweise und lässt sich manuell über workflow_dispatch starten; wurde ein Verband nur unvollständig abgerufen, endet er mit Fehler und committet nichts. Ohne Cache (oder wenn er älter als 150 Tage ist) funktioniert alles weiter, nur ohne Gruppierung nach Ebene.
Die HTML-Seiten am alten Fibalon-Pfad entfallen. Die alten Kalender-URLs (/{teamId}_{typ}.ics) bleiben bestehen.
Jedes Spiel wird als ICS-Event angelegt mit:
- Start: 1 Stunde vor Anpfiff (Ankunftszeit)
- Ende: 2,5 Stunden nach Anpfiff (geschätzte Spieldauer)
- Titel:
[H/A] HeimTeam vs. GastTeam - Beschreibung: Liga, Teams, Halle, Adresse, Anpfiffzeit
- Alarm: 30 min vorher (Heim), 60 min vorher (Auswärts)
- Ort: Adresse der Spielhalle
Jede Teamseite zeigt oben einen Teaser für das nächste Spiel.
- Kartendienst: Leaflet.js + OpenStreetMap (kein API-Key nötig)
- Geocoding: Nominatim — clientseitig, 1 Anfrage pro Seitenaufruf (Nutzungsbedingungen beachten)
- Navigation: Links zu Google Maps und Apple Maps mit vorausgefüllter Zieladresse
- Kein nächstes Spiel: Hinweis "Aktuell sind keine weiteren Spiele geplant."
GitHub Actions aktualisiert die Seite:
- Push auf
main— sofortiges Update - Cron
0 */6 * * *— alle 6 Stunden - Manuell — über das GitHub Actions UI
Schlägt die Verarbeitung einzelner Vereine fehl, deployt deploy.yml trotzdem die übrigen (sofern mindestens eine ICS-Datei erzeugt wurde) und markiert den Job am Ende als fehlgeschlagen. Das Saison-Archiv (generated/archive/**) wird auch bei solchen Teilfehlern committet, bei einem Totalausfall (keine ICS-Datei) dagegen nicht. Der WAM-Liga-Cache wird separat durch wam-refresh.yml quartalsweise aktualisiert.
npm install
# Alle Daten von der API laden und ICS + metadata.json + HTML generieren
# (pro Club aus clubs/<bundesland>/<club-slug>/config.json; genHTML() wird intern aufgerufen)
npm run update
# Lokalen Server starten (http://localhost:3000)
npm start
# WAM-Liga-Cache aktualisieren (data/wam-ligen-cache.json)
npm run wam:refresh
# Tests ausführen (Unit/E2E, ohne Netzwerk)
npm test
# Playwright: A11y- und UI-Tests ohne die Live-API-Smoke-Tests (wie im PR-Lauf)
npx playwright test --grep-invert @network
# API-Smoke-Tests gegen basketball-bund.net (Live-Netzwerk, prüfen die Antwortform aller genutzten Endpunkte)
npx playwright test --project=chromium-ui --grep @networkDie @network-Smoke-Tests (tests/ui/api.spec.js) laufen nicht im PR-Lauf, sondern wöchentlich über .github/workflows/api-smoke.yml (auch manuell per workflow_dispatch). Ein roter Lauf bedeutet, dass sich die Antwortform eines genutzten Endpunkts geändert hat.
| Paket | Version | Zweck |
|---|---|---|
| axios | ^1.7.9 | HTTP-Client für API-Anfragen |
| ics | ^3.8.1 | RFC 5545 ICS-Generierung |
| express | ^4.21.2 | Lokaler Entwicklungsserver |
| node-cron | ^4.6.0 | Scheduling |