wip
CI / build-and-test (pull_request) Failing after 49s

This commit is contained in:
2026-08-27 10:04:59 +02:00
parent 55a51a521d
commit c64889c3b1
66 changed files with 12623 additions and 483 deletions
+321
View File
@@ -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>
)
}