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 = { 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 = {} 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 halfHouseLabel: string selected: Set endpoints: Set onDayClick: (dateStr: string) => void }) { const first = days[0] if (!first) return null const leadingBlanks = dowMonStart(first.date) return ( {dowLabels.map((d, i) => ( {asI18n(`${d}`)} ))} {Array.from({ length: leadingBlanks }).map((_, 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 ? ( {asI18n(`${dateStr}`)} {d.bookings.map((b, i) => ( {asI18n(`${bookingLabel(b)}`)} ))} ) : d.eventName ? asI18n(`${dateStr} — ${d.eventName}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`) : asI18n(`${dateStr} — ${statusLabels[d.status]}${d.halfHouse ? ` (${halfHouseLabel})` : ''}`) return ( { if (d.status === 'free') onDayClick(dateStr) }} > {dayNum} {/* Single half-house booking keeps the corner dot; a split cell already communicates the half-house state visually. */} {d.halfHouse && !isSplit && ( )} ) })} ) } 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() 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() return new Set(rangeDates(sel.start, sel.end ?? sel.start)) }, [sel]) const endpointDates = useMemo(() => { if (!sel) return new Set() 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 {m.common__states__loading()} if (error) return {m.common__states__error()} if (!data) return null const statusLabels: Record = { 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 (