✨ first version

This commit is contained in:
2026-08-28 10:24:15 +02:00
parent 55a51a521d
commit ecbc42846b
72 changed files with 12788 additions and 483 deletions
+143
View File
@@ -0,0 +1,143 @@
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<number, OpeningRange[]> = {
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
}