322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|