wip
CI / build-and-test (push) Failing after 50s

This commit is contained in:
2026-08-25 12:04:06 +02:00
parent 55a51a521d
commit 0ffbad2855
36 changed files with 5015 additions and 275 deletions
@@ -0,0 +1,315 @@
import { useEffect, useRef, useState } from 'react'
import { formatDateOnly } from '#/lib/datetime'
import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours'
type Props = {
value: string
onChange: (value: string) => void
// "HH:MM" times already taken (e.g. by another reservation on the same table)
// for the currently selected date — shown disabled rather than removed, so the
// slot doesn't look like it doesn't exist, just that it isn't free.
bookedTimes?: string[]
// How far apart selectable times are, and how far in advance a booking must be
// made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: number
minLeadTimeMinutes?: number
// How far into the future a booking may be made. Undefined leaves the horizon open,
// since not every caller of this field needs the restriction.
maxLeadTimeDays?: number
}
type DayCell = { day: number; dateStr: string } | null
function splitValue(value: string) {
const [date = '', time = ''] = value.split('T')
return { date, time }
}
function getWeekday(dateStr: string) {
return new Date(`${dateStr}T00:00`).getDay()
}
function pad2(n: number) {
return String(n).padStart(2, '0')
}
function isoToDisplay(iso: string) {
if (!iso) return ''
const [year, month, day] = iso.split('-')
return `${day}.${month}.${year}`
}
// Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1".
function maskDateInput(raw: string) {
const digits = raw.replace(/\D/g, '').slice(0, 8)
let out = digits.slice(0, 2)
if (digits.length > 2) out += '.' + digits.slice(2, 4)
if (digits.length > 4) out += '.' + digits.slice(4, 8)
return out
}
// Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date.
function parseDisplayDate(raw: string) {
const digits = raw.replace(/\D/g, '')
if (digits.length !== 8) return null
const day = Number(digits.slice(0, 2))
const month = Number(digits.slice(2, 4))
const year = Number(digits.slice(4, 8))
const parsed = new Date(year, month - 1, day)
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
return null
}
return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`
}
// Monday-first weeks covering the given month, with null filler cells for days
// that belong to the adjacent month.
function getMonthGrid(year: number, month: number): DayCell[][] {
const firstOfMonth = new Date(year, month, 1)
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells: DayCell[] = Array(leadingBlanks).fill(null)
for (let day = 1; day <= daysInMonth; day++) {
cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` })
}
while (cells.length % 7 !== 0) cells.push(null)
const weeks: DayCell[][] = []
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
return weeks
}
const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function CalendarIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<rect x="3.5" y="5" width="17" height="16" rx="2" stroke="currentColor" strokeWidth="1.4" />
<path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
}
export function ReservationDateTimeField({
value,
onChange,
bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0,
maxLeadTimeDays,
}: Props) {
const { date, time } = splitValue(value)
const [dateText, setDateText] = useState(() => isoToDisplay(date))
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
const [view, setView] = useState(() => {
const [year, month] = (date || formatDateOnly(new Date())).split('-')
return { year: Number(year), month: Number(month) - 1 }
})
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setDateText(isoToDisplay(date))
}, [date])
useEffect(() => {
if (!isCalendarOpen) return
const [year, month] = (date || formatDateOnly(new Date())).split('-')
setView({ year: Number(year), month: Number(month) - 1 })
function handleOutsideClick(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsCalendarOpen(false)
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setIsCalendarOpen(false)
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
// Only re-run when the popover opens/closes — reopening should reset to the
// currently selected month, not follow every keystroke in the text field.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCalendarOpen])
const today = formatDateOnly(new Date())
const isPast = date !== '' && date < today
const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000)
const latestDate =
maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000))
const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate
// Past dates and dates beyond the booking horizon never have slots — shared by both
// the render below and commitDate, so a date committed from anywhere (typed, or
// otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes)
}
function isSelectableDate(dateStr: string) {
return optionsForDate(dateStr).length > 0
}
const allOptions = optionsForDate(date)
// Compares full timestamps (not just "is it today") so a lead time longer than
// what's left of today correctly spills over into tomorrow's early slots too.
const timeOptions = allOptions.filter(
(option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(),
)
const bookedTimesSet = new Set(bookedTimes)
const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option))
const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0
const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0
const dateIncomplete = parseDisplayDate(dateText) === null
function commitDate(newDate: string) {
const newOptions = optionsForDate(newDate)
const stillValid = newOptions.includes(time)
onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`)
}
function handleTextChange(raw: string) {
const masked = maskDateInput(raw)
setDateText(masked)
const parsed = parseDisplayDate(masked)
if (parsed) {
commitDate(parsed)
}
}
function handleDayPick(dateStr: string) {
commitDate(dateStr)
setIsCalendarOpen(false)
}
function handleTimeChange(newTime: string) {
onChange(`${date}T${newTime}`)
}
let timePlaceholder = 'Uhrzeit wählen'
if (dateIncomplete) timePlaceholder = 'Datum eingeben'
else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit'
else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft'
else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen'
else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar'
const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
})
const [todayYear, todayMonthStr] = today.split('-')
const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1
const isAtLatestMonth = (() => {
if (latestDate === null) return false
const [latestYear, latestMonthStr] = latestDate.split('-')
return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1
})()
function goToMonth(offset: number) {
setView((v) => {
const total = v.year * 12 + v.month + offset
return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 }
})
}
return (
<div className="datetime-field">
<div className="date-input-group" ref={containerRef}>
<input
type="text"
inputMode="numeric"
placeholder="TT.MM.JJJJ"
pattern="\d{2}\.\d{2}\.\d{4}"
maxLength={10}
value={dateText}
onChange={(e) => handleTextChange(e.target.value)}
required
/>
<button
type="button"
className="date-picker-trigger"
aria-label="Kalender öffnen"
aria-expanded={isCalendarOpen}
onClick={() => setIsCalendarOpen((open) => !open)}
>
<CalendarIcon />
</button>
{isCalendarOpen && (
<div className="date-popover" role="dialog" aria-label="Datum auswählen">
<div className="date-popover-header">
<button
type="button"
aria-label="Vorheriger Monat"
disabled={isAtEarliestMonth}
onClick={() => goToMonth(-1)}
>
‹
</button>
<span>{monthLabel}</span>
<button
type="button"
aria-label="Nächster Monat"
disabled={isAtLatestMonth}
onClick={() => goToMonth(1)}
>
›
</button>
</div>
<div className="date-popover-weekdays">
{WEEKDAY_HEADER_LABELS.map((label) => (
<span key={label}>{label}</span>
))}
</div>
<div className="date-popover-grid">
{getMonthGrid(view.year, view.month)
.flat()
.map((cell, index) => (
<button
key={cell?.dateStr ?? `blank-${index}`}
type="button"
className={cell?.dateStr === date ? 'is-selected' : undefined}
disabled={!cell || !isSelectableDate(cell.dateStr)}
onClick={() => cell && handleDayPick(cell.dateStr)}
>
{cell?.day ?? ''}
</button>
))}
</div>
</div>
)}
</div>
<select
value={time}
onChange={(e) => handleTimeChange(e.target.value)}
disabled={availableTimeOptions.length === 0}
required
>
<option value="" disabled>
{timePlaceholder}
</option>
{timeOptions.map((option) => (
<option key={option} value={option} disabled={bookedTimesSet.has(option)}>
{option}
{bookedTimesSet.has(option) ? ' (belegt)' : ''}
</option>
))}
</select>
</div>
)
}