Add occupancy pick-up history + bookability history popup

Schema: migrate newbook_occupancy_report_data from single-row upsert to
snapshot model (drop unique constraint, add valid_from / last_verified_at)
matching the pattern used by newbook_current_rates.

Backend: sync_occupancy now inserts a new row only when occupied/available/
maintenance figures change, otherwise bumps last_verified_at. New endpoint
GET /bookability/occupancy-history/{category_id}/{date} returns the timeline.
Rate matrix query updated to DISTINCT ON for the multi-row table.

Frontend: clicking any cell in the Bookability matrix opens a modal with
two stacked Plotly charts — rate history per tariff (step lines, green/red
markers for available/unavailable) and occupancy pick-up over time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:38:02 +00:00
parent aef7d755f1
commit 2743b0e877
4 changed files with 372 additions and 52 deletions

View file

@ -1,6 +1,8 @@
import React, { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import Plot from 'react-plotly.js'
import { X } from 'lucide-react'
import api from '../api'
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
@ -209,6 +211,12 @@ interface BookingAvailabilityData {
dates: Record<string, { status: string; rate: number | null }>
}
interface HistoryTarget {
categoryId: string
categoryName: string
date: string
}
const LoadingSpinner: React.FC = () => (
<div style={styles.loading}>
<div style={styles.spinner} />
@ -231,6 +239,7 @@ const Bookability: React.FC = () => {
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
})
const [refreshingDate, setRefreshingDate] = useState<string | null>(null)
const [history, setHistory] = useState<HistoryTarget | null>(null)
// Calculate date range from selected month
const { fromDate, toDate } = useMemo(() => {
@ -481,8 +490,11 @@ const Bookability: React.FC = () => {
style={mergeStyles(
styles.td,
styles.occupancyCell,
isWeekend(dateStr) ? styles.weekendCell : {}
isWeekend(dateStr) ? styles.weekendCell : {},
{ cursor: 'pointer' }
)}
title="Click for history"
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
-
</td>
@ -508,9 +520,11 @@ const Bookability: React.FC = () => {
styles.td,
styles.occupancyCell,
isWeekend(dateStr) ? styles.weekendCell : {},
getOccStyle()
getOccStyle(),
{ cursor: 'pointer' }
)}
title={`${occ.occupied} of ${bookableRooms} bookable rooms occupied${hasOffline ? ` (${occ.maintenance} offline)` : ''} - ${roomsLeft} left`}
title={`${occ.occupied} of ${bookableRooms} bookable rooms occupied${hasOffline ? ` (${occ.maintenance} offline)` : ''} - ${roomsLeft} left · click for history`}
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
<span style={styles.occupancyText}>
{occ.occupied}/{occ.available}
@ -553,7 +567,8 @@ const Bookability: React.FC = () => {
isWeekend(dateStr) ? styles.weekendCell : {},
noRoomsAvailable ? styles.cellNoRooms : styles.cellNoData
)}
title={noRoomsAvailable ? 'No rooms available' : undefined}
title={noRoomsAvailable ? 'No rooms available · click for history' : 'Click for history'}
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
-
</td>
@ -591,7 +606,8 @@ const Bookability: React.FC = () => {
getCellStyle(),
!isEffectivelyAvailable && !noRoomsAvailable ? { textDecoration: 'line-through' } : {}
)}
title={getTooltip()}
title={`${getTooltip()} · click for history`}
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
<span style={styles.cellContent}>
{tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')}
@ -704,7 +720,10 @@ const Bookability: React.FC = () => {
<span style={mergeStyles(styles.legendItem, styles.cellUnavailable)}>Unavailable</span>
<span style={mergeStyles(styles.legendItem, styles.cellNoRooms)}>No Rooms</span>
<span style={mergeStyles(styles.legendItem, styles.cellNoData)}>No Data</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-mid)' }}>Click any cell for rate &amp; occupancy history</span>
</div>
{history && <OccHistoryModal target={history} onClose={() => setHistory(null)} />}
</div>
)
}
@ -1100,4 +1119,169 @@ const styles: Record<string, React.CSSProperties> = {
},
}
// ─── Occupancy + Rate History Modal ──────────────────────────────────────────
interface RateHistorySnapshot {
valid_from: string
tariffs: { name: string; rate: number | null; available: boolean; min_stay?: number | null }[]
}
interface OccSnapshot {
valid_from: string
occupied: number
available: number
occupancy_pct: number | null
}
const PLOT_BASE = {
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' },
margin: { t: 10, r: 24, b: 48, l: 52 },
xaxis: { gridcolor: '#e5e9f0', zeroline: false },
yaxis: { gridcolor: '#e5e9f0', zeroline: false },
}
const TRACE_COLORS = ['#2563eb', '#d97706', '#7c3aed', '#059669', '#0891b2', '#be185d']
function OccHistoryModal({ target, onClose }: { target: HistoryTarget; onClose: () => void }) {
const { data: rateData, isLoading: rateLoading } = useQuery<{
history: RateHistorySnapshot[]
version_count: number
}>({
queryKey: ['bookability-rate-history', target.categoryId, target.date],
queryFn: () => api.get(`/bookability/rate-history/${target.categoryId}/${target.date}`).then(r => r.data),
})
const { data: occData, isLoading: occLoading } = useQuery<{ snapshots: OccSnapshot[] }>({
queryKey: ['bookability-occ-history', target.categoryId, target.date],
queryFn: () => api.get(`/bookability/occupancy-history/${target.categoryId}/${target.date}`).then(r => r.data),
})
const rateTraces = useMemo(() => {
const history = rateData?.history ?? []
if (!history.length) return []
const tariffNames = [...new Set(history.flatMap(h => h.tariffs.map(t => t.name)))]
return tariffNames.map((name, i) => {
const pts = history
.map(h => ({ x: h.valid_from, t: h.tariffs.find(t => t.name === name) }))
.filter(p => p.t?.rate != null)
return {
x: pts.map(p => p.x),
y: pts.map(p => p.t!.rate),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name,
line: { color: TRACE_COLORS[i % TRACE_COLORS.length], shape: 'hv' as const, width: 2 },
marker: {
size: 8,
color: pts.map(p => p.t!.available ? '#16a34a' : '#dc2626'),
symbol: pts.map(p => (p.t!.available ? 'circle' : 'x') as any),
line: { color: TRACE_COLORS[i % TRACE_COLORS.length], width: 1 },
},
}
})
}, [rateData])
const occTrace = useMemo(() => {
const snaps = occData?.snapshots ?? []
if (!snaps.length) return null
return {
x: snaps.map(s => s.valid_from),
y: snaps.map(s => s.occupancy_pct),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: 'Occupancy %',
fill: 'tozeroy' as const,
fillcolor: 'rgba(201,168,76,0.12)',
line: { color: 'var(--gold, #c9a84c)', shape: 'hv' as const, width: 2 },
marker: { size: 5, color: '#c9a84c' },
}
}, [occData])
const isLoading = rateLoading || occLoading
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.55)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div
className="card"
style={{ width: 'min(780px, 92vw)', maxHeight: '88vh', overflow: 'auto' }}
onClick={e => e.stopPropagation()}
>
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600 }}>{target.categoryName} {target.date}</span>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)' }} onClick={onClose}>
<X size={16} strokeWidth={1.75} />
</button>
</div>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 24 }}>
{isLoading && <div className="loading-state"><div className="spinner" />Loading history</div>}
{!isLoading && (
<>
{/* Rate history */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8 }}>
Rate History
{rateData?.version_count ? <span style={{ fontWeight: 400, marginLeft: 8 }}>({rateData.version_count} change{rateData.version_count !== 1 ? 's' : ''})</span> : null}
</div>
{!rateTraces.length ? (
<div style={{ padding: '20px 0', color: 'var(--text-mid)', fontSize: 13, textAlign: 'center' }}>No rate history recorded yet.</div>
) : (
<>
<Plot
data={rateTraces}
layout={{
...PLOT_BASE,
height: 240,
showlegend: true,
legend: { orientation: 'h' as const, y: -0.35, font: { size: 11 } },
yaxis: { ...PLOT_BASE.yaxis, tickprefix: '£', title: { text: '£ / night' } },
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 2 }}>
Green circle = available &nbsp;·&nbsp; Red = unavailable at that snapshot
</div>
</>
)}
</div>
{/* Occupancy history */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8 }}>
Occupancy Pick-up
{occData?.snapshots.length ? <span style={{ fontWeight: 400, marginLeft: 8 }}>({occData.snapshots.length} snapshot{occData.snapshots.length !== 1 ? 's' : ''})</span> : null}
</div>
{!occTrace ? (
<div style={{ padding: '20px 0', color: 'var(--text-mid)', fontSize: 13, textAlign: 'center' }}>
No occupancy snapshots yet pick-up history starts accumulating from first sync after this deployment.
</div>
) : (
<Plot
data={[occTrace]}
layout={{
...PLOT_BASE,
height: 180,
showlegend: false,
yaxis: { ...PLOT_BASE.yaxis, range: [0, 100], ticksuffix: '%', title: { text: 'Occ %' } },
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
)}
</div>
</>
)}
</div>
</div>
</div>
)
}
export default Bookability