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_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,