Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung

This commit is contained in:
2025-10-04 18:09:46 +02:00
parent 3a13c8dffb
commit 97c1d3493f
9 changed files with 1481 additions and 155 deletions

View File

@@ -0,0 +1,654 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
id: z.string(),
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
isActive: z.boolean(),
createdAt: z.string(),
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
slotDurationMinutes: z.number().int().min(1).optional(),
});
const TimeOffPeriodSchema = z.object({
id: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
reason: z.string(),
createdAt: z.string(),
});
export type RecurringRule = z.output<typeof RecurringRuleSchema>;
export type TimeOffPeriod = z.output<typeof TimeOffPeriodSchema>;
// KV-Stores
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import existing availability KV
import { router as availabilityRouter } from "./availability.js";
// Import bookings and treatments KV stores for getAvailableTimes endpoint
const bookingsKV = createKV<any>("bookings");
const treatmentsKV = createKV<any>("treatments");
// Owner-Authentifizierung (kopiert aus availability.ts)
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
const sessionsKV = createKV<Session>("sessions");
const usersKV = createKV<User>("users");
async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}
// Helper-Funktionen
function parseTime(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes; // Minuten seit Mitternacht
}
function formatTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper-Funktion zur Erkennung überlappender Regeln
function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string; endTime: string; id?: string }, existingRules: RecurringRule[]): RecurringRule[] {
const newStart = parseTime(newRule.startTime);
const newEnd = parseTime(newRule.endTime);
return existingRules.filter(rule => {
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
return false;
}
const existingStart = parseTime(rule.startTime);
const existingEnd = parseTime(rule.endTime);
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
return newStart < existingEnd && newEnd > existingStart;
});
}
// CRUD-Endpoints für Recurring Rules
const createRule = os
.input(
z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough()
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const id = randomUUID();
const rule: RecurringRule = {
id,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
isActive: true,
createdAt: new Date().toISOString(),
};
await recurringRulesKV.setItem(id, rule);
return rule;
} catch (err) {
console.error("recurring-availability.createRule error", err);
throw err;
}
});
const updateRule = os
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const { sessionId, ...rule } = input as any;
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
return rule as RecurringRule;
});
const deleteRule = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await recurringRulesKV.removeItem(input.id);
});
const toggleRuleActive = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const rule = await recurringRulesKV.getItem(input.id);
if (!rule) throw new Error("Regel nicht gefunden.");
rule.isActive = !rule.isActive;
await recurringRulesKV.setItem(input.id, rule);
return rule;
});
const listRules = os.handler(async () => {
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
const adminListRules = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
// CRUD-Endpoints für Time-Off Periods
const createTimeOff = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const id = randomUUID();
const timeOff: TimeOffPeriod = {
id,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
createdAt: new Date().toISOString(),
};
// Blockiere bestehende Slots in diesem Zeitraum
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
let blockedCount = 0;
for (const slot of existingSlots) {
if (slot.date >= input.startDate && slot.date <= input.endDate && slot.status === "free") {
await call(availabilityRouter.remove, { sessionId: input.sessionId, id: slot.id }, {});
blockedCount++;
}
}
if (blockedCount > 0) {
console.log(`Blocked ${blockedCount} existing slots for time-off period: ${input.reason}`);
}
await timeOffPeriodsKV.setItem(id, timeOff);
return { ...timeOff, blockedSlots: blockedCount };
} catch (err) {
console.error("recurring-availability.createTimeOff error", err);
throw err;
}
});
const updateTimeOff = os
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const { sessionId, ...timeOff } = input as any;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
return timeOff as TimeOffPeriod;
});
const deleteTimeOff = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await timeOffPeriodsKV.removeItem(input.id);
});
const listTimeOff = os.handler(async () => {
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
const adminListTimeOff = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
// Slot-Generator-Endpoint
// DEPRECATED: This endpoint will be removed in a future version.
// The system is transitioning to dynamic availability calculation with 15-minute intervals.
// Slots are no longer pre-generated based on recurring rules.
const generateSlots = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
overwriteExisting: z.boolean().default(false),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
// Validierung: maximal 12 Wochen Zeitraum
const start = new Date(input.startDate);
const end = new Date(input.endDate);
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > 84) { // 12 Wochen = 84 Tage
throw new Error("Zeitraum darf maximal 12 Wochen betragen.");
}
// Lade alle aktiven Regeln
const allRules = await recurringRulesKV.getAllItems();
const activeRules = allRules.filter(rule => rule.isActive);
// Lade alle Urlaubszeiten
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
// Lade bestehende Slots (ohne Auto-Cleanup)
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
// Erstelle Set für effiziente Duplikat-Prüfung
const existing = new Set(existingSlots.map(s => `${s.date}T${s.time}`));
let created = 0;
let skipped = 0;
let updated = 0;
// Iteriere über jeden Tag im Zeitraum
const currentDate = new Date(start);
while (currentDate <= end) {
const dateStr = formatDate(currentDate);
// Verwende lokale Datumskomponenten für korrekte Wochentag-Berechnung
const [y, m, d] = dateStr.split('-').map(Number);
const localDate = new Date(y, m - 1, d);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Prüfe, ob Datum in einer Urlaubszeit liegt
if (isDateInTimeOffPeriod(dateStr, timeOffPeriods)) {
currentDate.setDate(currentDate.getDate() + 1);
continue;
}
// Finde alle Regeln für diesen Wochentag
const matchingRules = activeRules.filter(rule => rule.dayOfWeek === dayOfWeek);
for (const rule of matchingRules) {
// Skip rules without slotDurationMinutes (legacy field for deprecated generateSlots)
if (!rule.slotDurationMinutes) {
console.log(`Skipping rule ${rule.id} - no slotDurationMinutes defined (legacy field)`);
continue;
}
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
// Generiere Slots in slotDurationMinutes-Schritten
let currentMinutes = startMinutes;
while (currentMinutes + rule.slotDurationMinutes <= endMinutes) {
const timeStr = formatTime(currentMinutes);
const key = `${dateStr}T${timeStr}`;
// Prüfe, ob bereits ein Slot für dieses Datum+Zeit existiert
if (existing.has(key)) {
if (input.overwriteExisting) {
// Finde den bestehenden Slot für Update
const existingSlot = existingSlots.find(
slot => slot.date === dateStr && slot.time === timeStr
);
if (existingSlot && existingSlot.status === "free") {
// Überschreibe Dauer des bestehenden Slots
const updatedSlot = {
...existingSlot,
durationMinutes: rule.slotDurationMinutes,
};
await call(availabilityRouter.update, {
sessionId: input.sessionId,
...updatedSlot
}, {});
updated++;
} else {
skipped++;
}
} else {
skipped++;
}
} else {
// Erstelle neuen Slot mit try/catch für Duplikat-Konflikte
try {
await call(availabilityRouter.create, {
sessionId: input.sessionId,
date: dateStr,
time: timeStr,
durationMinutes: rule.slotDurationMinutes,
}, {});
existing.add(key);
created++;
} catch (err: any) {
// Behandle bekannte Duplikat-Fehler
if (err.message && err.message.includes("bereits ein Slot")) {
skipped++;
} else {
throw err; // Re-throw unbekannte Fehler
}
}
}
currentMinutes += rule.slotDurationMinutes;
}
}
currentDate.setDate(currentDate.getDate() + 1);
}
const message = `${created} Slots erstellt, ${updated} aktualisiert, ${skipped} übersprungen.`;
console.log(`Slot generation completed: ${message}`);
return {
created,
updated,
skipped,
message,
};
} catch (err) {
console.error("recurring-availability.generateSlots error", err);
throw err;
}
});
// Get Available Times Endpoint
const getAvailableTimes = os
.input(
z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
treatmentId: z.string(),
})
)
.handler(async ({ input }) => {
try {
// Validate that the date is not in the past
const today = new Date();
const inputDate = new Date(input.date);
today.setHours(0, 0, 0, 0);
inputDate.setHours(0, 0, 0, 0);
if (inputDate < today) {
return [];
}
// Get treatment duration
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
const treatmentDuration = treatment.duration;
// Parse the date to get day of week
const [year, month, day] = input.date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule =>
rule.isActive === true && rule.dayOfWeek === dayOfWeek
);
if (matchingRules.length === 0) {
return []; // No rules for this day of week
}
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
return []; // Date is blocked by time-off period
}
// Generate 15-minute intervals with boundary alignment
const availableTimes: string[] = [];
// Helper functions for 15-minute boundary alignment
const ceilTo15 = (m: number) => m % 15 === 0 ? m : m + (15 - (m % 15));
const floorTo15 = (m: number) => m - (m % 15);
for (const rule of matchingRules) {
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
let currentMinutes = ceilTo15(startMinutes);
const endBound = floorTo15(endMinutes);
while (currentMinutes + treatmentDuration <= endBound) {
const timeStr = formatTime(currentMinutes);
availableTimes.push(timeStr);
currentMinutes += 15; // 15-minute intervals
}
}
// Get all bookings for this date and their treatments
const allBookings = await bookingsKV.getAllItems();
const dateBookings = allBookings.filter(booking =>
booking.appointmentDate === input.date &&
['pending', 'confirmed', 'completed'].includes(booking.status)
);
// Optimize treatment duration lookup with Map caching
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map<string, number>();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Get treatment durations for all bookings using the cached map
const bookingTreatments = new Map();
for (const booking of dateBookings) {
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
bookingTreatments.set(booking.id, duration);
}
// Filter out booking conflicts
const availableTimesFiltered = availableTimes.filter(slotTime => {
const slotStartMinutes = parseTime(slotTime);
const slotEndMinutes = slotStartMinutes + treatmentDuration;
// Check if this slot overlaps with any existing booking
const hasConflict = dateBookings.some(booking => {
const bookingStartMinutes = parseTime(booking.appointmentTime);
const bookingDuration = bookingTreatments.get(booking.id) || 60;
const bookingEndMinutes = bookingStartMinutes + bookingDuration;
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
});
return !hasConflict;
});
// Filter out past times for today
const now = new Date();
const isToday = inputDate.getTime() === today.getTime();
const finalAvailableTimes = isToday
? availableTimesFiltered.filter(timeStr => {
const slotTime = parseTime(timeStr);
const currentTime = now.getHours() * 60 + now.getMinutes();
return slotTime > currentTime;
})
: availableTimesFiltered;
// Deduplicate and sort chronologically
const unique = Array.from(new Set(finalAvailableTimes));
return unique.sort((a, b) => a.localeCompare(b));
} catch (err) {
console.error("recurring-availability.getAvailableTimes error", err);
throw err;
}
});
// Live-Queries
const live = {
listRules: os.handler(async function* ({ signal }) {
yield call(listRules, {}, { signal });
for await (const _ of recurringRulesKV.subscribe()) {
yield call(listRules, {}, { signal });
}
}),
listTimeOff: os.handler(async function* ({ signal }) {
yield call(listTimeOff, {}, { signal });
for await (const _ of timeOffPeriodsKV.subscribe()) {
yield call(listTimeOff, {}, { signal });
}
}),
adminListRules: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedRules;
for await (const _ of recurringRulesKV.subscribe()) {
const updatedRules = await recurringRulesKV.getAllItems();
const sortedUpdatedRules = updatedRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedUpdatedRules;
}
}),
adminListTimeOff: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff;
for await (const _ of timeOffPeriodsKV.subscribe()) {
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedUpdatedTimeOff;
}
}),
};
export const router = {
// Recurring Rules
createRule,
updateRule,
deleteRule,
toggleRuleActive,
listRules,
adminListRules,
// Time-Off Periods
createTimeOff,
updateTimeOff,
deleteTimeOff,
listTimeOff,
adminListTimeOff,
// Generator
generateSlots,
// Availability
getAvailableTimes,
// Live queries
live,
};