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