diff --git a/backend/api/bookability.py b/backend/api/bookability.py index 27d2b0e..af4198d 100644 --- a/backend/api/bookability.py +++ b/backend/api/bookability.py @@ -259,9 +259,10 @@ async def get_rate_matrix( rates_result = await db.execute(text(rates_query), rates_params) rates_rows = rates_result.fetchall() - # Fetch occupancy data from newbook_occupancy_report_data + # Fetch occupancy data — latest snapshot per category/date 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 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_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_rows = occupancy_result.fetchall() @@ -728,6 +731,56 @@ def _refresh_date_sync(rate_date: date): 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}") async def refresh_single_date( rate_date: str, diff --git a/backend/jobs/sync_occupancy.py b/backend/jobs/sync_occupancy.py index b0adfa1..215dba7 100644 --- a/backend/jobs/sync_occupancy.py +++ b/backend/jobs/sync_occupancy.py @@ -1,6 +1,11 @@ """ Occupancy report sync — populates newbook_occupancy_report_data so the 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 from datetime import date, timedelta @@ -10,9 +15,76 @@ from sqlalchemy import text 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): """ - 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. """ 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}") vat = float(client.vat_rate or 0) - records = 0 + inserted = 0 + verified = 0 for category in report: category_id = str(category.get("category_id") or "") @@ -38,57 +111,31 @@ async def run_sync_occupancy(days_ahead: int = 365): for date_str, day in (category.get("occupancy") or {}).items(): try: - report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str - available = int(day.get("available", 0) or 0) - occupied = int(day.get("occupied", 0) or 0) - maintenance = int(day.get("maintenance", 0) or 0) - allotted = int(day.get("allotted", 0) or 0) + report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str + available = int(day.get("available", 0) or 0) + occupied = int(day.get("occupied", 0) or 0) + maintenance = int(day.get("maintenance", 0) or 0) + allotted = int(day.get("allotted", 0) or 0) revenue_gross = float(day.get("revenue_gross", 0) or 0) - revenue_net = day.get("revenue_net") + revenue_net = day.get("revenue_net") if revenue_net is None: revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross occupancy_pct = (occupied / available * 100) if available > 0 else 0 - 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, 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 - category_name = EXCLUDED.category_name, - available = EXCLUDED.available, - occupied = EXCLUDED.occupied, - 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), - } + result = await _save_occupancy_snapshot( + db, category_id, category_name, report_date, + occupied, available, maintenance, allotted, + revenue_gross, revenue_net, occupancy_pct ) - records += 1 + if result == 'inserted': + inserted += 1 + else: + verified += 1 + except Exception as e: logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}") await db.rollback() await db.commit() - logger.info(f"Occupancy sync complete: {records} records upserted") - return records + logger.info(f"Occupancy sync complete: {inserted} new snapshots, {verified} unchanged") + return inserted + verified diff --git a/backend/schema.sql b/backend/schema.sql index f23570e..3c72a2e 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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); +-- 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 -- ============================================ diff --git a/frontend/src/pages/Bookability.tsx b/frontend/src/pages/Bookability.tsx index e9d5367..396d4f1 100644 --- a/frontend/src/pages/Bookability.tsx +++ b/frontend/src/pages/Bookability.tsx @@ -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 } +interface HistoryTarget { + categoryId: string + categoryName: string + date: string +} + const LoadingSpinner: React.FC = () => (
@@ -231,6 +239,7 @@ const Bookability: React.FC = () => { return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` }) const [refreshingDate, setRefreshingDate] = useState(null) + const [history, setHistory] = useState(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 })} > - @@ -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 })} > {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 })} > - @@ -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 })} > {tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')} @@ -704,7 +720,10 @@ const Bookability: React.FC = () => { Unavailable No Rooms No Data + Click any cell for rate & occupancy history
+ + {history && setHistory(null)} />}
) } @@ -1100,4 +1119,169 @@ const styles: Record = { }, } +// ─── 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 ( +
+
e.stopPropagation()} + > +
+ {target.categoryName} — {target.date} + +
+ +
+ {isLoading &&
Loading history…
} + + {!isLoading && ( + <> + {/* Rate history */} +
+
+ Rate History + {rateData?.version_count ? ({rateData.version_count} change{rateData.version_count !== 1 ? 's' : ''}) : null} +
+ {!rateTraces.length ? ( +
No rate history recorded yet.
+ ) : ( + <> + +
+ Green circle = available  ·  Red ✕ = unavailable at that snapshot +
+ + )} +
+ + {/* Occupancy history */} +
+
+ Occupancy Pick-up + {occData?.snapshots.length ? ({occData.snapshots.length} snapshot{occData.snapshots.length !== 1 ? 's' : ''}) : null} +
+ {!occTrace ? ( +
+ No occupancy snapshots yet — pick-up history starts accumulating from first sync after this deployment. +
+ ) : ( + + )} +
+ + )} +
+
+
+ ) +} + export default Bookability