Files
promo_steakhouse/src/components/reservation-charts.tsx
T
lukas c65842ff5c
CI / test (push) Successful in 5m5s
CI / build (push) Successful in 4m6s
CI / deploy (push) Successful in 44s
✨ first version
2026-08-28 08:00:21 +02:00

322 lines
10 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 { 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<Granularity, string> = {
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<DateBucketGranularity, number> = {
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 (
<div className="bar-chart">
<div className="bar-chart-plot">
{ticks.map((tick) => (
<div key={tick} className="bar-chart-gridline" style={{ bottom: `${(tick / chartMax) * 100}%` }}>
<span className="bar-chart-tick">{tick}</span>
</div>
))}
<div className="bar-chart-bars">
{data.map((d, i) => (
<div className="bar-chart-col" key={i} tabIndex={0} aria-label={d.tooltip}>
<div className="bar-chart-bar" style={{ height: `${(d.value / chartMax) * 100}%` }}>
{showValueLabels && d.value > 0 && <span className="bar-chart-value">{d.value}</span>}
</div>
<div className="bar-chart-tooltip">{d.tooltip}</div>
</div>
))}
</div>
</div>
<div className="bar-chart-axis">
{data.map((d, i) => (
<span key={i} className="bar-chart-axis-label">
{d.label}
</span>
))}
</div>
</div>
)
}
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 (
<div className="chart-card">
<div className="chart-card-header">
<h3 className="chart-title">{title}</h3>
{headerExtra}
</div>
{hasData ? <BarChartBody data={data} showValueLabels={showValueLabels} /> : <p className="empty-state">{emptyMessage}</p>}
</div>
)
}
function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) {
return (
<div className="chart-toggle" role="group" aria-label="Zeitraum">
{(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => (
<button
key={g}
type="button"
className={g === value ? 'is-active' : undefined}
aria-pressed={g === value}
onClick={() => onChange(g)}
>
{GRANULARITY_LABELS[g]}
</button>
))}
</div>
)
}
export function ReservationCharts({ reservations, tables }: Props) {
const [granularity, setGranularity] = useState<Granularity>('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 (
<div className="charts-grid">
<ChartCard
title="Reservierungen"
headerExtra={<GranularityToggle value={granularity} onChange={setGranularity} />}
data={timeline.data}
emptyMessage={timeline.emptyMessage}
/>
<ChartCard
title="Reservierungen je Tisch"
data={tableCounts}
showValueLabels
emptyMessage={timeline.emptyMessage}
/>
</div>
)
}