import { formatDateOnly } from '#/lib/datetime' export const RESTAURANT_TIME_ZONE = 'Europe/Berlin' export const RESERVATION_INTERVAL_MINUTES = 15 export type OpeningRange = { start: string; end: string } // Keyed by the JS `Date#getDay()` convention: 0 = Sunday … 6 = Saturday. const OPENING_HOURS_BY_WEEKDAY: Record = { 0: [ { start: '11:30', end: '13:30' }, { start: '17:00', end: '22:00' }, ], 1: [], 2: [{ start: '17:00', end: '21:30' }], 3: [{ start: '17:00', end: '21:30' }], 4: [{ start: '17:00', end: '21:30' }], 5: [{ start: '17:00', end: '22:00' }], 6: [{ start: '17:00', end: '22:00' }], } const WEEKDAY_LABELS_MONDAY_FIRST: Array<{ day: string; weekday: number }> = [ { day: 'Montag', weekday: 1 }, { day: 'Dienstag', weekday: 2 }, { day: 'Mittwoch', weekday: 3 }, { day: 'Donnerstag', weekday: 4 }, { day: 'Freitag', weekday: 5 }, { day: 'Samstag', weekday: 6 }, { day: 'Sonntag', weekday: 0 }, ] const WEEKDAY_SHORT_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const berlinPartsFormatter = new Intl.DateTimeFormat('en-US', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }) function toMinutes(time: string) { const [hours, minutes] = time.split(':').map(Number) return hours * 60 + minutes } function getBerlinTimeParts(date: Date) { const parts = berlinPartsFormatter.formatToParts(date) const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) return { weekday: WEEKDAY_SHORT_NAMES.indexOf(byType.weekday), minutesSinceMidnight: Number(byType.hour) * 60 + Number(byType.minute), } } export function isOnReservationInterval(date: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES) { return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0 } // `durationMinutes` requires the whole reservation — not just its start — to fit // before closing, so a slot too close to close time is rejected outright. export function isWithinOpeningHours(date: Date, durationMinutes = 0) { const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date) const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] return ranges.some( (range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight + durationMinutes <= toMinutes(range.end), ) } export function getOpeningHoursForDisplay() { return WEEKDAY_LABELS_MONDAY_FIRST.map(({ day, weekday }) => { const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] return { day, hours: ranges.length ? ranges.map((range) => `${range.start} – ${range.end}`) : null, } }) } export function getTodayLabelBerlin() { return new Intl.DateTimeFormat('de-DE', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'long' }).format(new Date()) } // The functions below drive the customer-facing date/time picker. They work with the // browser's local time (not RESTAURANT_TIME_ZONE) since visitors are assumed to be // browsing from the restaurant's own timezone — same assumption the rest of the // booking form already makes when it builds a Date from an unqualified local string. export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] { return OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] } // `durationMinutes` (how long a reservation booked at a slot would occupy the // table) defaults to `intervalMinutes` so a bare call still behaves sensibly — // but every real caller should pass the restaurant's actual reservation // duration, so a slot too close to closing time to fit the whole reservation // is excluded rather than offered and then silently running past close. export function generateTimeOptions( ranges: OpeningRange[], intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, durationMinutes: number = intervalMinutes, ): string[] { const options: string[] = [] for (const range of ranges) { const startMinutes = toMinutes(range.start) const endMinutes = toMinutes(range.end) for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) { const hours = Math.floor(minutes / 60) const remainder = minutes % 60 options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`) } } return options } export function getNextAvailableSlot( from: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, durationMinutes: number = intervalMinutes, ): { date: string; time: string } | null { for (let dayOffset = 0; dayOffset < 14; dayOffset++) { const day = new Date(from) day.setDate(from.getDate() + dayOffset) const dateStr = formatDateOnly(day) const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes) for (const time of options) { const slot = new Date(`${dateStr}T${time}`) if (slot.getTime() >= from.getTime()) { return { date: dateStr, time } } } } return null }