Buchungssystem

TL;DR: Was als simples Embed mit hardcoded Daten startete, ist heute ein vollständig dynamisches Buchungssystem mit Supabase-Backend, Cloudflare Worker API und flexibler Schichtplanung. Entwickelt in iterativen Sessions mit Claude als AI-Pair-Programmer. Eine technische Reise durch Zeitzonen-Bugs, CORS-Policies und die Frage: Wie viel Flexibilität braucht ein Therapeuten-Kalender wirklich?
Agentic AI Development: So bauen wir Software
Bevor wir in die Technik einsteigen: Dieses Projekt haben wir mit Agentic AI Development umgesetzt. Statt klassischer Entwicklung (Spec schreiben → Entwickler briefen → Wochen warten) arbeiten wir iterativ mit Claude als AI-Partner.
Der entscheidende Unterschied: Claude kennt den Kontext. Die Datenbank-Tabellen, die wir vorher gebaut haben. Das CSS vom Widget. Die Business-Logik. Jede Session baut auf der vorherigen auf, kein Onboarding, kein "lass mich dir nochmal erklären, was wir machen".
Das Muster:
- Problem beschreiben ("Ab März haben wir eine zweite Therapeutin")
- Claude schlägt Architektur vor – basierend auf dem, was schon da ist
- Ich gebe Business-Kontext ("Arbeitszeiten sind jeden Tag anders")
- Claude generiert Code, der zu unseren bestehenden Tabellen passt
- Bug gefunden → nächste Iteration
Keine Tickets, keine Sprints, keine Übergaben. Ein fortlaufendes Gespräch über ~15 Sessions in 2 Wochen.
Das Ergebnis: Ein Production-System, das drei konkrete Business-Anforderungen löst, die mit hardcoded Daten nicht machbar waren.
Der Ausgangspunkt: Ein Widget für den Smoketest
Anfang Januar 2026 stand die mobile Hundephysiotherapie "Foty" kurz vor dem Launch. Die Idee: Kunden sollen online Termine buchen können, direkt auf der Webflow-Website, ohne Telefon, ohne E-Mail-Ping-Pong.
Für den ersten Smoketest haben wir ein simples HTML/CSS/JS-Widget gebaut:
// V1: Alles hardcoded
const BOOKABLE_DATES = ['2026-01-07', '2026-01-08', '2026-01-09'];
const TIME_SLOTS = ['09:00', '10:00', '11:00', '12:00'];
const serviceAreas = [
{ name: "Blankenese", postal_codes: ["22587", "22589"] },
{ name: "Eppendorf", postal_codes: ["20249", "20251"] },
// ...
];
Das Widget hatte alles, was man für einen ersten Test braucht:
- ✅ PLZ-Validierung (ist der Service in diesem Gebiet verfügbar?)
- ✅ Kalender mit Datumsauswahl
- ✅ Zeitslot-Auswahl
- ✅ Kontaktformular für Kunde + Hund
- ✅ Webhook zu Make.com für Benachrichtigungen
Problem: Jede Änderung – neue Zeiten, neue Gebiete, neue Preise, gebuchte Termine – bedeutete Code-Änderungen und Re-Deploy auf Webflow.
Die drei Treiber: Warum hardcoded nicht mehr reichte
Treiber 1: Die Schichtplanung
Unsere Therapeuten haben keine festen Arbeitszeiten. Montag 9-15, Dienstag 13-19, Mittwoch frei. Und nächste Woche wieder anders. Ein hardcoded Array für Zeitslots funktioniert genau so lange, bis jemand fragt: "Kann ich nächsten Mittwoch doch arbeiten?"
Treiber 2: Manuelle Termine & Verschiebungen
Nicht alle Termine kommen über das Widget. Julia trägt auch Folgetermine Vorort ein. Oder verschiebt einen Termin. Das Widget und unsere AI Agents wissen davon nichts, sie zeigen fröhlich 14:00 als verfügbar an, obwohl Julia da längst bei Herrn Müller und seinem Labrador ist.
Treiber 3: Die zweite Therapeutin
"Ab März haben wir Sarah als zweite Therapeutin. Die Buchungen müssen auf beide verteilt werden."
Der Gamechanger. Plötzlich reichte ein hardcoded Array endgültig nicht mehr. Wir brauchten:
- Echte Therapeuten-Verwaltung – Wer arbeitet wann?
- Dynamische Verfügbarkeit – Basierend auf Schichtplanung
- Kollisionsprüfung – Keine Doppelbuchungen
- Flexibilität – Zeiten können täglich variieren
Zeit für ein echtes Backend.
Die Architektur-Entscheidung: Supabase + Cloudflare Worker
Nach kurzer Überlegung fiel die Wahl auf:
Datenbank Supabase (PostgreSQL) Bereits für die interne App im Einsatz, RPC-Funktionen, Row Level Security
API-Layer Cloudflare Worker Edge-Performance, einfaches Deployment, CORS-Handling
Frontend Vanilla JS im Webflow Embed Keine Build-Pipeline, direktes Copy-Paste
Die Idee: Das Widget bleibt "dumm" – es ruft nur APIs auf. Die gesamte Logik (Verfügbarkeit berechnen, Termine prüfen, Buchung anlegen) passiert in PostgreSQL-Funktionen.
Das Datenmodell: Flexibilität durch Trennung
Behandlungstypen
CREATE TABLE behandlungstypen (
id UUID PRIMARY KEY,
slug TEXT UNIQUE, -- 'erstbefund-therapie'
name TEXT, -- 'Erstbefund + Therapie'
beschreibung TEXT, -- Für die Treatment Card
badge TEXT, -- 'Empfohlen für Neukunden'
subtitle TEXT, -- 'Der optimale Start'
preis DECIMAL, -- 99.00
alter_preis DECIMAL, -- 129.00 (durchgestrichen)
dauer_minuten INTEGER, -- 90
sortierung INTEGER -- Reihenfolge
);
Alles, was auf den Treatment Cards im Widget angezeigt wird, kommt aus der Datenbank.
Schichtplanung: Die größte Herausforderung
Ursprünglich hatten wir feste Schichten:
-- V1: Starre Schichten
CREATE TABLE schichten (
name TEXT, -- 'Frühschicht'
start_zeit TIME, -- 08:00
ende_zeit TIME -- 14:00
);
Das Problem: Therapeuten haben keine festen Arbeitszeiten. Montag 9-15 Uhr, Dienstag 13-19 Uhr, Mittwoch frei.
Die Lösung: Flexible Zeiten pro Tag
-- V2: Individuelle Zeiten
CREATE TABLE schichtplanung (
therapeut_id UUID,
datum DATE,
schicht_id UUID, -- Verknüpfung zum Typ (Arbeit/Frei/Urlaub)
start_zeit TIME, -- Individuell pro Tag!
ende_zeit TIME,
status TEXT -- 'Freigegeben', 'Entwurf', 'Abgelehnt'
);
Jetzt kann die Schichtplanung so aussehen:
02.02. Julia 09:00 15:00 (Freigegeben)
03.02. Julia 13:00 19:00 (Freigegeben)
04.02. Julia 09:00 16:00 (Freigegeben)
05.02. Julia Frei (Freigegeben)
Die Slot-Berechnung: Wo die Magie passiert
Die wichtigste Funktion im System: get_verfuegbare_slots()
Diese Funktion löst Treiber #2 – sie berücksichtigt alle Termine, egal ob über das Widget gebucht oder manuell von Julia eingetragen.
-- Vereinfachte Darstellung
WITH arbeitszeiten AS (
SELECT therapeut_id, start_zeit, ende_zeit
FROM schichtplanung
WHERE datum = p_datum
AND status = 'Freigegeben'
AND schicht_typ = 'Arbeit'
),
alle_slots AS (
SELECT generate_series(
start_zeit,
ende_zeit - behandlungsdauer - padding,
'15 minutes'::INTERVAL
) AS slot_zeit
FROM arbeitszeiten
),
gebuchte_termine AS (
SELECT start_zeit, ende_zeit
FROM termine
WHERE datum = p_datum
AND status NOT IN ('Storniert', 'Abgesagt')
)
SELECT slot_zeit
FROM alle_slots
WHERE NOT EXISTS (
-- Kollisionsprüfung
SELECT 1 FROM gebuchte_termine
WHERE slot überschneidet sich mit bestehendem Termin
);
Was hier passiert:
- Hole die Arbeitszeiten des Tages (aus schichtplanung)
- Generiere 15-Minuten-Slots innerhalb dieser Zeiten
- Filtere Slots raus, die mit bestehenden Terminen kollidieren
- Berücksichtige Padding (30 Min zwischen Terminen für Anfahrt)
- Berücksichtige Vorlaufzeit (mind. 4 Stunden vor Termin buchbar)
Das Ergebnis: Eine Liste verfügbarer Zeitslots, die das Widget einfach anzeigt.
Der Timezone-Bug: Eine Stunde, die alles ändert
Nach dem ersten Live-Test kam der Anruf: "Der Termin steht für 11 Uhr im Kalender, aber die Kundin hat 10 Uhr gebucht!"
Das Problem: PostgreSQL speichert Zeiten in UTC. Wenn das Widget "10:00" schickt und wir das direkt als TIMESTAMPTZ casten, interpretiert Postgres es als 10:00 UTC – also 11:00 deutscher Zeit.
Vorher (Bug):
v_start_zeit := (p_datum || ' ' || p_start_zeit)::TIMESTAMPTZ;
-- '10:00' wird als 10:00 UTC gespeichert → 11:00 in Deutschland
Nachher (Fix):
v_start_zeit := ((p_datum || ' ' || p_start_zeit)::TIMESTAMP)
AT TIME ZONE 'Europe/Berlin';
-- '10:00' wird als 10:00 Berlin interpretiert → 09:00 UTC gespeichert
Lektion gelernt: Zeitzonen sind kein Edge Case. Sie sind der Case.
Der Cloudflare Worker: 50 Zeilen, die alles verbinden
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
// CORS für erlaubte Origins
const corsHeaders = getCorsHeaders(request);
if (path === '/api/behandlungen') {
return handleBehandlungen(env, corsHeaders);
}
if (path === '/api/buchbare-tage') {
return handleBuchbareTage(url, env, corsHeaders);
}
if (path === '/api/slots') {
return handleSlots(url, env, corsHeaders);
}
if (path === '/api/buchung') {
return handleBuchung(request, env, corsHeaders);
}
return jsonResponse({ error: 'Not found' }, 404);
}
};
Der Worker ist bewusst "dumm" – er leitet Anfragen an Supabase-RPC-Funktionen weiter und gibt die Ergebnisse zurück. Die Logik bleibt in der Datenbank.
Warum nicht direkt Supabase aufrufen?
- CORS-Kontrolle – Nur foty.de darf die API nutzen
- Rate Limiting – Cloudflare bietet das out-of-the-box
- Abstraktion – Das Widget kennt keine Supabase-Details
- Monitoring – Cloudflare Analytics zeigt alle Requests
Das Widget: Vom Monolith zum modularen System
V1 war eine einzige Datei mit allem inline – 500 Zeilen HTML/CSS/JS strukturiert.
V2 ist dynamisch:
// Konfiguration
const API_BASE = 'https://your-worker.workers.dev/api';
// State Management
const S = {
step: 1,
treatments: [],
buchbareTage: {},
data: { /* Formulardaten */ }
};
// Module
const Treat = { load(), render(), select() };
const PLZ = { check() };
const Cal = { loadBuchbareTage(), render() };
const Slots = { load(), select() };
const Book = { confirm(), trackConversion(), sendWebhook() };
const Timer = { start(), tick(), stop() };
const UI = { show(), hide(), err() };
Jedes Modul hat eine klare Aufgabe. Das macht Debugging deutlich einfacher.
Learnings & Best Practices
1. Logik in die Datenbank, nicht ins Frontend
PostgreSQL-Funktionen sind mächtig. Slot-Berechnung, Kollisionsprüfung, Buchungserstellung – alles in einer Transaktion, atomar und sicher.
2. CORS ist kein Afterthought
Von Anfang an nur erlaubte Origins akzeptieren. Sonst testet jemand eure API mit Postman und ihr wundert euch über Fake-Buchungen.
3. Zeitzonen früh klären
Die Frage "In welcher Zeitzone speichern wir?" sollte vor der ersten Zeile Code beantwortet sein. Unser Ansatz: Alles in UTC speichern, bei Ein-/Ausgabe nach Europe/Berlin konvertieren.
4. Inline Styles > CSS-Spezifität-Kämpfe
Webflow hat eigene Styles. Statt sich mit !important-Kaskaden zu quälen, setzen wir kritische Styles (selected state, disabled state) direkt per JavaScript als inline styles.
5. Webhooks als Backup, nicht als Primary
Die Buchung wird in Supabase gespeichert. Der Make.com-Webhook ist nur Backup für Benachrichtigungen. Wenn Make.com down ist, geht trotzdem keine Buchung verloren.
Was kommt als Nächstes?
- Behandlungsdokumentation – Therapeuten dokumentieren direkt nach dem Termin
- Kunden-App – Termine einsehen, Folgetermin buchen
- Automatische Erinnerungen – 24h vor Termin per SMS/E-Mail
- Analytics Dashboard – Auslastung, Conversion Rate, beliebte Zeitslots
Fazit
Was als "schnelles Embed für den Smoketest" begann, ist heute ein vollwertiges Buchungssystem. Der Schlüssel war, früh die richtige Architektur zu wählen: Supabase für Daten und Logik, Cloudflare Worker als API-Gateway, und ein Widget, das nur darstellt, was das Backend liefert.
Die größte Erkenntnis? Flexibilität kostet. Nicht in Euro, sondern in Komplexität. Die Entscheidung, Arbeitszeiten pro Tag statt pro Woche zu speichern, hat drei Tabellen und zwei Funktions-Refactorings gekostet. Aber jetzt kann Julia montags von 9-15 Uhr und dienstags von 13-19 Uhr arbeiten, ohne dass jemand Code anfassen muss.
Warum Agentic AI Development? Weil die Feedback-Loop so kurz ist. Problem beschreiben → Lösung diskutieren → Code generieren → testen → nächste Iteration. In 15 Sessions über 2 Wochen. Ohne Tickets, ohne Sprints, ohne Wartezeiten.
Sarah startet im März. Ihr erster Termin ist schon über das Widget gebucht. Und wenn sie fragt "Kann ich mittwochs auch Videoberatungen machen?", weiß ich schon, wie die nächste Session mit Claude beginnt.
Der Maßstab für gute Software bleibt: Können die Nutzer ihre Arbeit machen, ohne Entwickler zu fragen?
Über Arial.
Wir bauen das Neue.
Geschäftsmodelle mit AI im Kern. Ideen, die gerade entstehen. Ventures, die intern nicht die Priorität bekommen. Oder ein kompletter Neustart für Unternehmen, die Legacy überspringen statt transformieren wollen. Wir bauen robuste Systeme. In Wochen, nicht Jahren.
Was wir auch tun: Automation und AI Agents, die an Bestehendes andocken.
Was wir nicht tun: Legacy transformieren. SAP-Salesforce-ERP-Migrationen. Das ist nicht unser Fokus. Wir bauen, was noch nicht existiert. Mit den Daten, die zählen.
