Pierre Laub endurance × creativity

Den Marathonplan als Kalender abonnieren

Eine Sache hat mich an der App-Form des Trainingsplans von Anfang an gestört: Man muss aktiv auf die Seite gehen, um zu sehen, was heute ansteht. Für Montagmorgen um 6 Uhr ist das keine gute UX.

Naheliegende Lösung: iCalendar-Feed. Einmal abonnieren, und alle 161 Trainingseinheiten erscheinen als Ganztagestermine im eigenen Kalender — Apple Calendar, Google Calendar, Outlook, alles was .ics versteht. Den Abonnement-Link gibt es jetzt direkt im Plan-Header.


Die URL ist personalisiert

Das Interessante: Der Feed ist nicht statisch. Die Abonnement-URL sieht so aus:

webcal://pierrelaub.de/marathon-calendar.ics?goalTime=12600

goalTime ist die Zielzeit in Sekunden — 12600 für 3:30:00. Wer im Plan auf 4:00 umgestellt hat, bekommt einen Link mit ?goalTime=14400. Der Server führt dann dieselben Pace-Substitutionen durch wie die Vue-App — nur serverseitig, direkt in die ICS-Beschreibungen eingebettet. Aus "5×1000m @ 4:42" wird für eine 4:00-Zielzeit "5×1000m @ 5:25", so wie es auch im Plan selbst angezeigt wird.

Der Link im Header aktualisiert sich reaktiv mit der Zielzeit — wer die Zielzeit ändert, sieht sofort die neue URL und kann sie als frisches Abonnement hinzufügen.


Technisch: ein Server-Endpoint im statischen Projekt

Die restliche Seite bleibt statisch generiert. Für den ICS-Endpoint war das keine Option — Query-Parameter wie ?goalTime sind zur Buildzeit nicht bekannt.

Lösung: @astrojs/node als Adapter, der Endpoint bekommt export const prerender = false. Alle anderen Seiten bleiben unverändert. Astro baut dann zwei Ausgaben: statische HTML-Dateien für alles andere, und einen Node.js-Server der nur für /marathon-calendar.ics zuständig ist. Im Dockerfile fällt NGINX weg, stattdessen läuft direkt node dist/server/entry.mjs.

Das ICS-Format ist überraschend boilerplate-lastig: Plain Text, zwingend CRLF-Zeilenenden, Zeilen dürfen nicht länger als 75 Bytes sein (RFC 5545 §3.1). Die 75-Byte-Grenze gilt in Bytes, nicht Zeichen — relevant weil ⭐ und 🏁 in den Einheitstiteln als UTF-8 je 3–4 Bytes belegen. Ein kleiner Fold-Helper mit TextEncoder löst das sauber.

Alle 161 Tage landen im Kalender — auch Ruhetage — damit die Woche vollständig erscheint. Der Renntag am 25. Oktober bekommt LOCATION:Frankfurt am Main und STATUS:CONFIRMED. Mit REFRESH-INTERVAL:P1W holen Kalender-Apps den Feed wöchentlich neu, sodass Änderungen am Plan nach einem Redeploy automatisch ankommen.


Den Plan gibt es hier: /marathon