Files
promo_steakhouse/src/lib/opening-hours.ts
T
lukas 4ed5bc47f6
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 2m41s
CI / deploy (push) Successful in 17s
✨ first version
2026-08-28 09:49:01 +02:00

144 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}