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:
parent
aef7d755f1
commit
2743b0e877
4 changed files with 372 additions and 52 deletions
|
|
@ -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 & 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 · 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue