Files
seminarhof-e2e-mrfyfnqt/apps/app/src/pages/availability.tsx
2026-07-11 07:59:25 +02:00

403 lines
14 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 { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Button,
Container,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from '@pikku/mantine/core'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { MarketingShell } from '../components/MarketingShell'
import { PageTitle } from '../components/AppLayout'
// Brand-aligned: free is the inviting state (cream + plum text, clickable),
// confirmed is fully booked (filled plum), enquiry/reserved sit between.
const STATUS_BG: Record<string, { bg: string; fg: string }> = {
free: { bg: '#FBF8FA', fg: '#4E3149' },
enquiry: { bg: '#FFF8E0', fg: '#806600' },
reserved: { bg: '#FFEDDC', fg: '#8A4A1A' },
confirmed: { bg: '#88557F', fg: '#FFFFFF' },
}
const STATUS_KEYS = ['free', 'enquiry', 'reserved', 'confirmed'] as const
type DayBooking = {
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
}
type DayCell = {
date: Date
status: typeof STATUS_KEYS[number]
eventName: string | null
halfHouse: boolean
bookings: DayBooking[]
}
// Berlin-local YYYY-MM-DD string from a Date
const berlinStr = (d: Date) => d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
function groupByMonth(days: DayCell[]) {
const months: Record<string, DayCell[]> = {}
for (const d of days) {
const key = berlinStr(d.date).slice(0, 7)
;(months[key] ??= []).push(d)
}
return Object.entries(months).map(([key, ds]) => ({ key, days: ds }))
}
function monthLabel(yyyymm: string, locale: string) {
const [y, m] = yyyymm.split('-')
const d = new Date(Number(y), Number(m) - 1, 1)
return d.toLocaleString(locale, { month: 'long', year: 'numeric' })
}
function dowMonStart(d: Date) {
return (d.getUTCDay() + 6) % 7
}
// Inclusive list of YYYY-MM-DD strings between two date strings (UTC day steps).
function rangeDates(start: string, end: string): string[] {
const out: string[] = []
const cur = new Date(`${start}T00:00:00Z`)
const last = new Date(`${end}T00:00:00Z`)
while (cur <= last) {
out.push(cur.toISOString().slice(0, 10))
cur.setUTCDate(cur.getUTCDate() + 1)
}
return out
}
const SELECT_BG = { bg: '#88557F', fg: '#FFFFFF' } // endpoint
const SELECT_MID = { bg: '#EFE3ED', fg: '#4E3149' } // in-between
function MonthGrid({
days,
dowLabels,
statusLabels,
halfHouseLabel,
selected,
endpoints,
onDayClick,
}: {
days: DayCell[]
dowLabels: string[]
statusLabels: Record<string, string>
halfHouseLabel: string
selected: Set<string>
endpoints: Set<string>
onDayClick: (dateStr: string) => void
}) {
const first = days[0]
if (!first) return null
const leadingBlanks = dowMonStart(first.date)
return (
<Box>
<SimpleGrid cols={7} spacing={4}>
{dowLabels.map((d, i) => (
<Text key={i} size="xs" c="dimmed" ta="center">{asI18n(`${d}`)}</Text>
))}
{Array.from({ length: leadingBlanks }).map((_, i) => (
<Box key={`blank-${i}`} />
))}
{days.map((d) => {
const dateStr = berlinStr(d.date)
const isEndpoint = endpoints.has(dateStr)
const isSelected = selected.has(dateStr)
const style = isEndpoint ? SELECT_BG : isSelected ? SELECT_MID : STATUS_BG[d.status]
const dayNum = Number(dateStr.slice(8, 10))
// Two half-house bookings sharing a day → split the cell with a
// diagonal line (top-left = first booking, bottom-right = second).
// 3+ half-house bookings on one day (data error) intentionally fall
// back to the single collapsed colour — don't "fix" that into an
// N-way split without a real requirement.
const isSplit =
!isEndpoint && !isSelected &&
d.bookings.length === 2 && d.bookings.every((b) => b.halfHouse)
const splitBg = isSplit
? `linear-gradient(135deg, ${STATUS_BG[d.bookings[0].status].bg} 0 49%, #FFFFFF 49% 51%, ${STATUS_BG[d.bookings[1].status].bg} 51% 100%)`
: undefined
const bookingLabel = (b: DayBooking) =>
`${b.eventName ?? statusLabels[b.status]}${b.halfHouse ? ` (${halfHouseLabel})` : ''}`
const tooltipLabel = isSplit ? (
<Stack gap={0}>
<Text size="xs" fw={600}>{asI18n(`${dateStr}`)}</Text>
{d.bookings.map((b, i) => (
<Text key={i} size="xs">{asI18n(`${bookingLabel(b)}`)}</Text>
))}
</Stack>
) : d.eventName
? asI18n(`${dateStr}${d.eventName}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
: asI18n(`${dateStr}${statusLabels[d.status]}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`)
return (
<Tooltip key={dateStr} label={tooltipLabel} withArrow>
<Box
style={{
background: splitBg ?? style.bg,
color: isSplit ? '#4E3149' : style.fg,
borderRadius: 4,
textAlign: 'center',
padding: '6px 0',
fontSize: 13,
fontWeight: isEndpoint ? 700 : undefined,
boxShadow: isSelected && !isEndpoint ? 'inset 0 0 0 1px #88557F' : undefined,
cursor: d.status === 'free' ? 'pointer' : 'default',
position: 'relative',
}}
onClick={() => {
if (d.status === 'free') onDayClick(dateStr)
}}
>
<span style={isSplit ? { textShadow: '0 0 3px rgba(255,255,255,0.95)' } : undefined}>
{dayNum}
</span>
{/* Single half-house booking keeps the corner dot; a split cell
already communicates the half-house state visually. */}
{d.halfHouse && !isSplit && (
<Box
style={{
position: 'absolute',
top: 2,
right: 4,
width: 6,
height: 6,
borderRadius: 3,
background: 'currentColor',
opacity: 0.5,
}}
/>
)}
</Box>
</Tooltip>
)
})}
</SimpleGrid>
</Box>
)
}
const THIS_YEAR = new Date().getFullYear()
const YEAR_OPTIONS = [THIS_YEAR, THIS_YEAR + 1, THIS_YEAR + 2].map((y) => ({
value: String(y),
label: String(y),
}))
function AvailabilityPage() {
useLocale()
const navigate = useNavigate()
const [year, setYear] = useState(THIS_YEAR)
// Two-click range selection: first free day sets `start`, second sets `end`.
const [sel, setSel] = useState<{ start: string; end: string | null } | null>(null)
const from = `${year}-01-01`
const to = `${year}-12-31`
const { data, isLoading, error } = usePikkuQuery('getAvailability', { venueSlug: 'drawehn', from, to })
// Lookup of day status by Berlin-local date string, for validating ranges.
const statusByDate = useMemo(() => {
const m = new Map<string, DayCell['status']>()
for (const d of (data?.days as DayCell[] | undefined) ?? []) {
m.set(berlinStr(d.date), d.status)
}
return m
}, [data])
const handleDayClick = (ds: string) => {
setSel((prev) => {
// No selection yet, or a completed range → start fresh.
if (!prev || prev.end) return { start: ds, end: null }
// Re-click the start → deselect.
if (ds === prev.start) return null
// Earlier than start → move the start.
if (ds < prev.start) return { start: ds, end: null }
// Later than start → close the range only if every day in it is free.
const allFree = rangeDates(prev.start, ds).every((x) => statusByDate.get(x) === 'free')
return allFree ? { start: prev.start, end: ds } : { start: ds, end: null }
})
}
const selectedDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set(rangeDates(sel.start, sel.end ?? sel.start))
}, [sel])
const endpointDates = useMemo(() => {
if (!sel) return new Set<string>()
return new Set([sel.start, sel.end ?? sel.start])
}, [sel])
const months = useMemo(() => (data ? groupByMonth(data.days as DayCell[]) : []), [data])
const stats = useMemo(() => {
if (!data) return { free: 0, total: 0 }
const total = data.days.length
const free = data.days.filter((d) => d.status === 'free').length
return { free, total }
}, [data])
// Localised mon-start day-of-week labels.
const dowLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(getLocale(), { weekday: 'short' })
// 2024-01-01 was a Monday — convenient anchor.
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 1 + i))))
}, [getLocale()])
if (isLoading) return <MarketingShell><Container py="xl"><Text>{m.common__states__loading()}</Text></Container></MarketingShell>
if (error) return <MarketingShell><Container py="xl"><Text c="red">{m.common__states__error()}</Text></Container></MarketingShell>
if (!data) return null
const statusLabels: Record<string, string> = {
free: m.common__pages__availability__status__free(),
enquiry: m.common__pages__availability__status__enquiry(),
reserved: m.common__pages__availability__status__reserved(),
confirmed: m.common__pages__availability__status__confirmed(),
}
const halfHouseLabel = m.common__pages__availability__status__half_house()
// `enquiry` is only surfaced to staff (the backend hides it from anonymous
// viewers), so only show its legend swatch when such a day is actually
// present in the data.
const hasEnquiry = (data.days as DayCell[]).some((d) => d.status === 'enquiry')
const legendKeys = hasEnquiry ? STATUS_KEYS : STATUS_KEYS.filter((k) => k !== 'enquiry')
const fmtDate = (ds: string) =>
new Date(`${ds}T00:00:00Z`).toLocaleDateString(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
const selStart = sel?.start ?? null
const selEnd = sel?.end ?? null
const rangeLabel = selStart
? selEnd && selEnd !== selStart
? `${fmtDate(selStart)} ${fmtDate(selEnd)}`
: fmtDate(selStart)
: ''
const createEnquiry = () => {
if (!selStart) return
navigate({ to: '/enquiry', search: { from: selStart, to: selEnd ?? selStart } })
}
return (
<MarketingShell>
<Container size="lg" py="xl">
<Stack gap="lg">
<PageTitle title={m.common__pages__availability__title()} />
<Group align="flex-end" gap="md">
<Select
label={m.common__pages__availability__year_label()}
data={YEAR_OPTIONS}
value={String(year)}
onChange={(v) => {
if (v) {
setYear(Number(v))
setSel(null)
}
}}
allowDeselect={false}
w={120}
/>
<Text size="sm" pb={6}>
{m.common__pages__availability__free_stat({
free: stats.free,
total: stats.total,
percent: Math.round((stats.free / stats.total) * 100),
})}
</Text>
</Group>
<Group gap="md">
{legendKeys.map((key) => (
<Group key={key} gap={6} align="center">
<Box w={14} h={14} style={{ background: STATUS_BG[key].bg, borderRadius: 3 }} />
<Text size="xs">{asI18n(`${statusLabels[key]}`)}</Text>
</Group>
))}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{months.map((month) => (
<Paper key={month.key} withBorder radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4} tt="capitalize">{asI18n(`${monthLabel(month.key, getLocale())}`)}</Title>
<Badge variant="light">
{m.common__pages__availability__free_month_badge({
count: month.days.filter((d) => d.status === 'free').length,
})}
</Badge>
</Group>
<MonthGrid
days={month.days}
dowLabels={dowLabels}
statusLabels={statusLabels}
halfHouseLabel={halfHouseLabel}
selected={selectedDates}
endpoints={endpointDates}
onDayClick={handleDayClick}
/>
</Stack>
</Paper>
))}
</SimpleGrid>
{selStart && (
<Paper
withBorder
shadow="md"
radius="md"
p="md"
pos="sticky"
bottom={16}
style={{ zIndex: 2 }}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Stack gap={2}>
<Text fw={600}>
{m.common__pages__availability__selected_range({ range: rangeLabel })}
</Text>
{!selEnd && (
<Text size="xs" c="dimmed">
{m.common__pages__availability__select_end_hint()}
</Text>
)}
</Stack>
<Group gap="xs">
<Button variant="subtle" color="gray" onClick={() => setSel(null)}>
{m.common__pages__availability__clear_selection()}
</Button>
<Button data-testid="create-enquiry" onClick={createEnquiry}>
{m.common__pages__availability__create_enquiry()}
</Button>
</Group>
</Group>
</Paper>
)}
<Text size="xs" c="dimmed" ta="center">
{m.common__pages__availability__footnote()}
</Text>
</Stack>
</Container>
</MarketingShell>
)
}
export const Route = createFileRoute('/availability')({
component: AvailabilityPage,
})