141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
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:00', end: '20:00' }],
|
||
1: [],
|
||
2: [{ start: '11:00', end: '20:00' }],
|
||
3: [{ start: '11:00', end: '20:00' }],
|
||
4: [{ start: '11:00', end: '20:00' }],
|
||
5: [{ start: '11:00', end: '20:00' }],
|
||
6: [{ start: '11:00', end: '20: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
|
||
}
|