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

@ -259,9 +259,10 @@ async def get_rate_matrix(
rates_result = await db.execute(text(rates_query), rates_params) rates_result = await db.execute(text(rates_query), rates_params)
rates_rows = rates_result.fetchall() rates_rows = rates_result.fetchall()
# Fetch occupancy data from newbook_occupancy_report_data # Fetch occupancy data — latest snapshot per category/date
occupancy_query = """ occupancy_query = """
SELECT category_id, date, occupied, available, maintenance SELECT DISTINCT ON (category_id, date)
category_id, date, occupied, available, maintenance
FROM newbook_occupancy_report_data FROM newbook_occupancy_report_data
WHERE date >= :from_date AND date <= :to_date WHERE date >= :from_date AND date <= :to_date
""" """
@ -271,6 +272,8 @@ async def get_rate_matrix(
occupancy_query += " AND category_id = :category_id" occupancy_query += " AND category_id = :category_id"
occupancy_params["category_id"] = category_id occupancy_params["category_id"] = category_id
occupancy_query += " ORDER BY category_id, date, valid_from DESC"
occupancy_result = await db.execute(text(occupancy_query), occupancy_params) occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
occupancy_rows = occupancy_result.fetchall() occupancy_rows = occupancy_result.fetchall()
@ -728,6 +731,56 @@ def _refresh_date_sync(rate_date: date):
db.close() db.close()
# ============================================
# OCCUPANCY HISTORY ENDPOINT
# ============================================
@router.get("/occupancy-history/{category_id}/{rate_date}")
async def get_occupancy_history(
category_id: str,
rate_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get occupancy snapshot history for a specific category and stay date.
Returns all snapshots where occupied/available/maintenance changed,
ordered oldest-first so the frontend can build a pick-up timeline.
"""
try:
target_date = date.fromisoformat(rate_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
result = await db.execute(
text("""
SELECT valid_from, last_verified_at, occupied, available, maintenance, occupancy_pct
FROM newbook_occupancy_report_data
WHERE date = :date AND category_id = :category_id
ORDER BY valid_from ASC
"""),
{"date": target_date, "category_id": category_id}
)
rows = result.fetchall()
return {
"category_id": category_id,
"rate_date": rate_date,
"snapshots": [
{
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None,
"occupied": row.occupied,
"available": row.available,
"maintenance": row.maintenance,
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct is not None else None,
}
for row in rows
],
}
@router.post("/refresh-date/{rate_date}") @router.post("/refresh-date/{rate_date}")
async def refresh_single_date( async def refresh_single_date(
rate_date: str, rate_date: str,

View file

@ -1,6 +1,11 @@
""" """
Occupancy report sync populates newbook_occupancy_report_data so the Occupancy report sync populates newbook_occupancy_report_data so the
Bookability matrix can show availability alongside rates. Bookability matrix can show availability alongside rates.
Uses snapshot model (mirrors newbook_current_rates): only inserts a new row
when occupied/available/maintenance figures change; otherwise bumps
last_verified_at on the existing row. This lets the history popup show
how pick-up evolved over time for a given stay date.
""" """
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta
@ -10,9 +15,76 @@ from sqlalchemy import text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _save_occupancy_snapshot(db, category_id, category_name, report_date,
occupied, available, maintenance, allotted,
revenue_gross, revenue_net, occupancy_pct):
"""
Insert a new occupancy snapshot only when figures have changed;
otherwise update last_verified_at on the latest row.
Returns 'inserted' or 'verified'.
"""
existing = (await db.execute(
text("""
SELECT id, occupied, available, maintenance
FROM newbook_occupancy_report_data
WHERE date = :date AND category_id = :category_id
ORDER BY valid_from DESC
LIMIT 1
"""),
{"date": report_date, "category_id": category_id}
)).fetchone()
changed = (
existing is None
or int(existing.occupied or 0) != occupied
or int(existing.available or 0) != available
or int(existing.maintenance or 0) != maintenance
)
if changed:
await db.execute(
text("""
INSERT INTO newbook_occupancy_report_data (
date, category_id, category_name,
available, occupied, maintenance, allotted,
revenue_gross, revenue_net, occupancy_pct,
valid_from, last_verified_at, fetched_at
) VALUES (
:date, :category_id, :category_name,
:available, :occupied, :maintenance, :allotted,
:revenue_gross, :revenue_net, :occupancy_pct,
NOW(), NOW(), NOW()
)
"""),
{
"date": report_date,
"category_id": category_id,
"category_name": category_name,
"available": available,
"occupied": occupied,
"maintenance": maintenance,
"allotted": allotted,
"revenue_gross": round(revenue_gross, 2),
"revenue_net": round(revenue_net, 2),
"occupancy_pct": round(occupancy_pct, 2),
}
)
return 'inserted'
else:
await db.execute(
text("""
UPDATE newbook_occupancy_report_data
SET last_verified_at = NOW(), fetched_at = NOW()
WHERE id = :id
"""),
{"id": existing.id}
)
return 'verified'
async def run_sync_occupancy(days_ahead: int = 365): async def run_sync_occupancy(days_ahead: int = 365):
""" """
Fetch the Newbook occupancy report from today forward and upsert Fetch the Newbook occupancy report from today forward and snapshot
per-category, per-date availability into newbook_occupancy_report_data. per-category, per-date availability into newbook_occupancy_report_data.
""" """
from database import AsyncSessionLocal from database import AsyncSessionLocal
@ -28,7 +100,8 @@ async def run_sync_occupancy(days_ahead: int = 365):
logger.info(f"Occupancy report: {len(report)} categories, {from_date} to {to_date}") logger.info(f"Occupancy report: {len(report)} categories, {from_date} to {to_date}")
vat = float(client.vat_rate or 0) vat = float(client.vat_rate or 0)
records = 0 inserted = 0
verified = 0
for category in report: for category in report:
category_id = str(category.get("category_id") or "") category_id = str(category.get("category_id") or "")
@ -49,46 +122,20 @@ async def run_sync_occupancy(days_ahead: int = 365):
revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross
occupancy_pct = (occupied / available * 100) if available > 0 else 0 occupancy_pct = (occupied / available * 100) if available > 0 else 0
await db.execute( result = await _save_occupancy_snapshot(
text(""" db, category_id, category_name, report_date,
INSERT INTO newbook_occupancy_report_data ( occupied, available, maintenance, allotted,
date, category_id, category_name, revenue_gross, revenue_net, occupancy_pct
available, occupied, maintenance, allotted,
revenue_gross, revenue_net, occupancy_pct, fetched_at
) VALUES (
:date, :category_id, :category_name,
:available, :occupied, :maintenance, :allotted,
:revenue_gross, :revenue_net, :occupancy_pct, NOW()
) )
ON CONFLICT (date, category_id) DO UPDATE SET if result == 'inserted':
category_name = EXCLUDED.category_name, inserted += 1
available = EXCLUDED.available, else:
occupied = EXCLUDED.occupied, verified += 1
maintenance = EXCLUDED.maintenance,
allotted = EXCLUDED.allotted,
revenue_gross = EXCLUDED.revenue_gross,
revenue_net = EXCLUDED.revenue_net,
occupancy_pct = EXCLUDED.occupancy_pct,
fetched_at = NOW()
"""),
{
"date": report_date,
"category_id": category_id,
"category_name": category_name,
"available": available,
"occupied": occupied,
"maintenance": maintenance,
"allotted": allotted,
"revenue_gross": round(revenue_gross, 2),
"revenue_net": round(revenue_net, 2),
"occupancy_pct": round(occupancy_pct, 2),
}
)
records += 1
except Exception as e: except Exception as e:
logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}") logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}")
await db.rollback() await db.rollback()
await db.commit() await db.commit()
logger.info(f"Occupancy sync complete: {records} records upserted") logger.info(f"Occupancy sync complete: {inserted} new snapshots, {verified} unchanged")
return records return inserted + verified

View file

@ -225,6 +225,42 @@ CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data (
CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date); CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date);
-- Migrate occupancy table to snapshot model (drop unique constraint, add valid_from / last_verified_at)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'newbook_occupancy_report_data'
AND c.contype = 'u'
AND c.conname = 'newbook_occupancy_report_data_date_category_id_key'
) THEN
ALTER TABLE newbook_occupancy_report_data
DROP CONSTRAINT newbook_occupancy_report_data_date_category_id_key;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'valid_from'
) THEN
ALTER TABLE newbook_occupancy_report_data ADD COLUMN valid_from TIMESTAMPTZ;
UPDATE newbook_occupancy_report_data SET valid_from = fetched_at WHERE valid_from IS NULL;
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN valid_from SET DEFAULT NOW();
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'last_verified_at'
) THEN
ALTER TABLE newbook_occupancy_report_data ADD COLUMN last_verified_at TIMESTAMPTZ;
UPDATE newbook_occupancy_report_data SET last_verified_at = fetched_at WHERE last_verified_at IS NULL;
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN last_verified_at SET DEFAULT NOW();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_occupancy_report_latest
ON newbook_occupancy_report_data(date, category_id, valid_from DESC);
-- ============================================ -- ============================================
-- DIRECT COMPETITOR HOTEL CONFIGS -- DIRECT COMPETITOR HOTEL CONFIGS
-- ============================================ -- ============================================

View file

@ -1,6 +1,8 @@
import React, { useState, useMemo } from 'react' import React, { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import Plot from 'react-plotly.js'
import { X } from 'lucide-react'
import api from '../api' import api from '../api'
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) // 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 }> dates: Record<string, { status: string; rate: number | null }>
} }
interface HistoryTarget {
categoryId: string
categoryName: string
date: string
}
const LoadingSpinner: React.FC = () => ( const LoadingSpinner: React.FC = () => (
<div style={styles.loading}> <div style={styles.loading}>
<div style={styles.spinner} /> <div style={styles.spinner} />
@ -231,6 +239,7 @@ const Bookability: React.FC = () => {
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
}) })
const [refreshingDate, setRefreshingDate] = useState<string | null>(null) const [refreshingDate, setRefreshingDate] = useState<string | null>(null)
const [history, setHistory] = useState<HistoryTarget | null>(null)
// Calculate date range from selected month // Calculate date range from selected month
const { fromDate, toDate } = useMemo(() => { const { fromDate, toDate } = useMemo(() => {
@ -481,8 +490,11 @@ const Bookability: React.FC = () => {
style={mergeStyles( style={mergeStyles(
styles.td, styles.td,
styles.occupancyCell, 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> </td>
@ -508,9 +520,11 @@ const Bookability: React.FC = () => {
styles.td, styles.td,
styles.occupancyCell, styles.occupancyCell,
isWeekend(dateStr) ? styles.weekendCell : {}, 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}> <span style={styles.occupancyText}>
{occ.occupied}/{occ.available} {occ.occupied}/{occ.available}
@ -553,7 +567,8 @@ const Bookability: React.FC = () => {
isWeekend(dateStr) ? styles.weekendCell : {}, isWeekend(dateStr) ? styles.weekendCell : {},
noRoomsAvailable ? styles.cellNoRooms : styles.cellNoData 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> </td>
@ -591,7 +606,8 @@ const Bookability: React.FC = () => {
getCellStyle(), getCellStyle(),
!isEffectivelyAvailable && !noRoomsAvailable ? { textDecoration: 'line-through' } : {} !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}> <span style={styles.cellContent}>
{tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')} {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.cellUnavailable)}>Unavailable</span>
<span style={mergeStyles(styles.legendItem, styles.cellNoRooms)}>No Rooms</span> <span style={mergeStyles(styles.legendItem, styles.cellNoRooms)}>No Rooms</span>
<span style={mergeStyles(styles.legendItem, styles.cellNoData)}>No Data</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> </div>
{history && <OccHistoryModal target={history} onClose={() => setHistory(null)} />}
</div> </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 export default Bookability