import { useState } from 'react' import { getOpeningRangesForWeekday, type OpeningRange } from '#/lib/opening-hours' type ReservationLike = { tableId: number reservationTime: Date } type TableLike = { id: number name: string } type Props = { reservations: ReservationLike[] tables: TableLike[] } type BarDatum = { label: string value: number tooltip: string } type Granularity = 'today' | 'week' | 'month' type DateBucketGranularity = 'week' | 'month' const GRANULARITY_LABELS: Record = { today: 'Heute', week: 'Woche', month: 'Monat', } // How many buckets — and how far out — each granularity covers: enough weeks/months // to spot a trend without the axis becoming unreadable. const BUCKET_COUNTS: Record = { week: 8, month: 6, } const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }) const MONTH_AXIS_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'short' }) const MONTH_TOOLTIP_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) function reservationsLabel(count: number) { return count === 1 ? 'Reservierung' : 'Reservierungen' } function addBucketUnit(date: Date, granularity: DateBucketGranularity, amount: number) { const d = new Date(date) if (granularity === 'week') d.setDate(d.getDate() + amount * 7) else d.setMonth(d.getMonth() + amount) return d } // The start of the first bucket: the Monday of this week, or the 1st of this month — // so the current, still-in-progress bucket covers the whole period, not just what's // left of it (the caller is expected to pass in ALL reservations, not just upcoming // ones, so this week's/month's earlier days are still counted). function bucketRangeStart(granularity: DateBucketGranularity) { const now = new Date() now.setHours(0, 0, 0, 0) if (granularity === 'month') { now.setDate(1) return now } const mondayOffset = (now.getDay() + 6) % 7 now.setDate(now.getDate() - mondayOffset) return now } function formatBucketLabel(start: Date, granularity: DateBucketGranularity) { if (granularity === 'month') return MONTH_AXIS_FORMATTER.format(start) return WEEK_LABEL_FORMATTER.format(start) } function formatBucketTooltip(start: Date, end: Date, granularity: DateBucketGranularity, count: number) { const countLabel = `${count} ${reservationsLabel(count)}` if (granularity === 'month') return `${MONTH_TOOLTIP_FORMATTER.format(start)}: ${countLabel}` const lastDayOfWeek = new Date(end.getTime() - 24 * 60 * 60 * 1000) return `${WEEK_LABEL_FORMATTER.format(start)}–${WEEK_LABEL_FORMATTER.format(lastDayOfWeek)}: ${countLabel}` } function buildBucketedCounts(reservations: ReservationLike[], granularity: DateBucketGranularity): BarDatum[] { const rangeStart = bucketRangeStart(granularity) const bucketCount = BUCKET_COUNTS[granularity] return Array.from({ length: bucketCount }, (_, i) => { const start = addBucketUnit(rangeStart, granularity, i) const end = addBucketUnit(rangeStart, granularity, i + 1) const count = reservations.filter((r) => { const time = new Date(r.reservationTime).getTime() return time >= start.getTime() && time < end.getTime() }).length return { label: formatBucketLabel(start, granularity), value: count, tooltip: formatBucketTooltip(start, end, granularity, count), } }) } // The restaurant can have more than one service window in a day (e.g. lunch + // dinner) — span the whole gap between them rather than faceting the chart, so a // quiet hour between services reads as "no bookings then", not a missing bar. function hourSpanForRanges(ranges: OpeningRange[]) { const startHour = Math.min(...ranges.map((r) => Number(r.start.split(':')[0]))) const endHour = Math.max( ...ranges.map((r) => { const [hour, minute] = r.end.split(':').map(Number) return minute > 0 ? hour + 1 : hour }), ) return { startHour, endHour } } function buildHourlyCountsForToday(reservations: ReservationLike[], ranges: OpeningRange[]): BarDatum[] { const { startHour, endHour } = hourSpanForRanges(ranges) const dayStart = new Date() dayStart.setHours(0, 0, 0, 0) return Array.from({ length: endHour - startHour }, (_, i) => { const hour = startHour + i const start = new Date(dayStart) start.setHours(hour) const end = new Date(dayStart) end.setHours(hour + 1) const count = reservations.filter((r) => { const time = new Date(r.reservationTime).getTime() return time >= start.getTime() && time < end.getTime() }).length return { label: `${String(hour).padStart(2, '0')}:00`, value: count, tooltip: `${String(hour).padStart(2, '0')}:00–${String(hour + 1).padStart(2, '0')}:00: ${count} ${reservationsLabel(count)}`, } }) } function buildTimelineData( reservations: ReservationLike[], granularity: Granularity, ): { data: BarDatum[]; emptyMessage: string } { if (granularity === 'today') { const ranges = getOpeningRangesForWeekday(new Date().getDay()) if (ranges.length === 0) { return { data: [], emptyMessage: 'Heute ist Ruhetag.' } } return { data: buildHourlyCountsForToday(reservations, ranges), emptyMessage: 'Heute sind noch keine Reservierungen eingetragen.', } } return { data: buildBucketedCounts(reservations, granularity), emptyMessage: 'In diesem Zeitraum sind noch keine Reservierungen eingetragen.', } } // The full date range the timeline chart currently covers, so the per-table chart // can be filtered to the same window and the two charts always tell one consistent // story instead of the table breakdown silently covering a different span. function getTimelineRange(granularity: Granularity): { start: Date; end: Date } { if (granularity === 'today') { const start = new Date() start.setHours(0, 0, 0, 0) const end = new Date(start) end.setDate(end.getDate() + 1) return { start, end } } const start = bucketRangeStart(granularity) const end = addBucketUnit(start, granularity, BUCKET_COUNTS[granularity]) return { start, end } } function buildTableCounts(reservations: ReservationLike[], tables: TableLike[]): BarDatum[] { return tables.map((table) => { const count = reservations.filter((r) => r.tableId === table.id).length return { label: table.name, value: count, tooltip: `${table.name}: ${count} ${reservationsLabel(count)}`, } }) } // Produces clean, evenly-stepped tick values (0, 2, 4, 6, …) so the gridlines // never land on an awkward number like 7 or 13. function niceTicks(maxValue: number, tickCount = 4): number[] { if (maxValue <= 0) return [0, 1] const rawStep = maxValue / (tickCount - 1) const magnitude = 10 ** Math.floor(Math.log10(rawStep)) const residual = rawStep / magnitude const niceResidual = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10 // Counts are always whole numbers, so a sub-1 step (e.g. 0.5) would just // round two distinct ticks down to the same displayed integer. const step = Math.max(1, niceResidual * magnitude) const niceMax = Math.ceil(maxValue / step) * step const ticks: number[] = [] for (let v = 0; v <= niceMax + 1e-9; v += step) ticks.push(Math.round(v)) return ticks } function BarChartBody({ data, showValueLabels }: { data: BarDatum[]; showValueLabels?: boolean }) { const ticks = niceTicks(Math.max(...data.map((d) => d.value), 0)) const chartMax = ticks[ticks.length - 1] || 1 return (
{ticks.map((tick) => (
{tick}
))}
{data.map((d, i) => (
{showValueLabels && d.value > 0 && {d.value}}
{d.tooltip}
))}
{data.map((d, i) => ( {d.label} ))}
) } function ChartCard({ title, headerExtra, data, showValueLabels, emptyMessage, }: { title: string headerExtra?: React.ReactNode data: BarDatum[] showValueLabels?: boolean emptyMessage: string }) { const hasData = data.some((d) => d.value > 0) return (

{title}

{headerExtra}
{hasData ? :

{emptyMessage}

}
) } function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) { return (
{(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => ( ))}
) } export function ReservationCharts({ reservations, tables }: Props) { const [granularity, setGranularity] = useState('today') const timeline = buildTimelineData(reservations, granularity) const { start, end } = getTimelineRange(granularity) const reservationsInRange = reservations.filter((r) => { const time = new Date(r.reservationTime).getTime() return time >= start.getTime() && time < end.getTime() }) const tableCounts = buildTableCounts(reservationsInRange, tables) return (
} data={timeline.data} emptyMessage={timeline.emptyMessage} />
) }