✨ first version
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user