Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
22
backend/utils/__init__.py
Normal file
22
backend/utils/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""
|
||||
Backend utilities module.
|
||||
"""
|
||||
from .time_alignment import (
|
||||
get_prior_year_daily,
|
||||
get_prior_year_weekly,
|
||||
get_prior_year_week_dates,
|
||||
get_prior_year_monthly,
|
||||
get_comparison_info,
|
||||
SQL_PRIOR_YEAR_DAILY,
|
||||
SQL_PRIOR_YEAR_OFFSET,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'get_prior_year_daily',
|
||||
'get_prior_year_weekly',
|
||||
'get_prior_year_week_dates',
|
||||
'get_prior_year_monthly',
|
||||
'get_comparison_info',
|
||||
'SQL_PRIOR_YEAR_DAILY',
|
||||
'SQL_PRIOR_YEAR_OFFSET',
|
||||
]
|
||||
116
backend/utils/capacity.py
Normal file
116
backend/utils/capacity.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Room capacity utilities
|
||||
|
||||
Functions for getting bookable room counts accounting for maintenance.
|
||||
"""
|
||||
from datetime import date
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def get_bookable_cap_sync(db, forecast_date: date = None, fallback_value: int = 25) -> int:
|
||||
"""
|
||||
Get the bookable rooms cap for a specific date (synchronous version).
|
||||
|
||||
Bookable = Total Rooms - Maintenance - Allotted
|
||||
|
||||
Args:
|
||||
db: Synchronous database session
|
||||
forecast_date: Specific date to get cap for (optional)
|
||||
fallback_value: Default if no data found
|
||||
|
||||
Returns:
|
||||
Bookable room count (cap for room forecasts)
|
||||
"""
|
||||
# First try to get specific date's bookable count from stats
|
||||
if forecast_date:
|
||||
result = db.execute(text("""
|
||||
SELECT bookable_count
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :target_date AND bookable_count IS NOT NULL
|
||||
"""), {"target_date": forecast_date})
|
||||
row = result.fetchone()
|
||||
if row and row.bookable_count is not None:
|
||||
return int(row.bookable_count)
|
||||
|
||||
# Try occupancy report data for future dates
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
SUM(COALESCE(o.available, 0) - COALESCE(o.maintenance, 0)) as bookable
|
||||
FROM newbook_occupancy_report_data o
|
||||
JOIN newbook_room_categories c ON o.category_id = c.site_id
|
||||
WHERE o.date = :target_date AND c.is_included = true
|
||||
"""), {"target_date": forecast_date})
|
||||
row = result.fetchone()
|
||||
if row and row.bookable is not None:
|
||||
return int(row.bookable)
|
||||
|
||||
# Fall back to most recent bookable_count
|
||||
result = db.execute(text("""
|
||||
SELECT bookable_count
|
||||
FROM newbook_bookings_stats
|
||||
WHERE bookable_count IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
if row and row.bookable_count:
|
||||
return int(row.bookable_count)
|
||||
|
||||
return fallback_value
|
||||
|
||||
|
||||
async def get_bookable_cap(db, forecast_date: date = None, fallback_value: int = 25) -> int:
|
||||
"""
|
||||
Get the bookable rooms cap for a specific date.
|
||||
|
||||
Bookable = Total Rooms - Maintenance - Allotted
|
||||
|
||||
For future dates without stats data, tries occupancy report data first,
|
||||
then falls back to most recent bookable_count from stats.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
forecast_date: Specific date to get cap for (optional)
|
||||
fallback_value: Default if no data found
|
||||
|
||||
Returns:
|
||||
Bookable room count (cap for room forecasts)
|
||||
"""
|
||||
# First try to get specific date's bookable count from stats
|
||||
if forecast_date:
|
||||
result = await db.execute(text("""
|
||||
SELECT bookable_count
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :target_date AND bookable_count IS NOT NULL
|
||||
"""), {"target_date": forecast_date})
|
||||
row = result.fetchone()
|
||||
# Accept 0 as valid (all rooms in maintenance)
|
||||
if row and row.bookable_count is not None:
|
||||
return int(row.bookable_count)
|
||||
|
||||
# Try occupancy report data for future dates
|
||||
result = await db.execute(text("""
|
||||
SELECT
|
||||
SUM(COALESCE(o.available, 0) - COALESCE(o.maintenance, 0)) as bookable
|
||||
FROM newbook_occupancy_report_data o
|
||||
JOIN newbook_room_categories c ON o.category_id = c.site_id
|
||||
WHERE o.date = :target_date AND c.is_included = true
|
||||
"""), {"target_date": forecast_date})
|
||||
row = result.fetchone()
|
||||
# Accept 0 as valid bookable count (all rooms in maintenance)
|
||||
if row and row.bookable is not None:
|
||||
return int(row.bookable)
|
||||
|
||||
# Fall back to most recent bookable_count
|
||||
result = await db.execute(text("""
|
||||
SELECT bookable_count
|
||||
FROM newbook_bookings_stats
|
||||
WHERE bookable_count IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
if row and row.bookable_count:
|
||||
return int(row.bookable_count)
|
||||
|
||||
return fallback_value
|
||||
161
backend/utils/time_alignment.py
Normal file
161
backend/utils/time_alignment.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""
|
||||
Time alignment utilities for prior year comparisons.
|
||||
|
||||
Ensures consistent comparison logic across the application:
|
||||
- Daily: 364 days (52 weeks) for day-of-week alignment (Mon→Mon, Sat→Sat)
|
||||
- Weekly: ISO week number matching (Week 6 2026 vs Week 6 2025)
|
||||
- Monthly: Same month, prior year
|
||||
|
||||
This module should be used whenever comparing to prior year data.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
def get_prior_year_daily(target_date: date) -> date:
|
||||
"""
|
||||
Get the comparable date from prior year for daily comparisons.
|
||||
|
||||
Uses 364 days (exactly 52 weeks) to ensure day-of-week alignment:
|
||||
- Monday → Monday
|
||||
- Saturday → Saturday
|
||||
|
||||
Example:
|
||||
Wed 11 Feb 2026 → Wed 12 Feb 2025 (not 11 Feb 2025 which was a different day)
|
||||
|
||||
Args:
|
||||
target_date: The date to find comparison for
|
||||
|
||||
Returns:
|
||||
The prior year date with same day of week
|
||||
"""
|
||||
return target_date - timedelta(days=364)
|
||||
|
||||
|
||||
def get_prior_year_weekly(target_date: date) -> Tuple[int, int]:
|
||||
"""
|
||||
Get the ISO week and year for weekly year-over-year comparison.
|
||||
|
||||
Uses ISO week numbers so Week 6 of 2026 compares to Week 6 of 2025.
|
||||
This ensures full weeks are compared (Mon-Sun) regardless of calendar dates.
|
||||
|
||||
Args:
|
||||
target_date: Any date within the target week
|
||||
|
||||
Returns:
|
||||
Tuple of (year, week_number) for the comparison week
|
||||
"""
|
||||
iso_cal = target_date.isocalendar()
|
||||
return (iso_cal.year - 1, iso_cal.week)
|
||||
|
||||
|
||||
def get_prior_year_week_dates(target_date: date) -> Tuple[date, date]:
|
||||
"""
|
||||
Get the start and end dates of the same ISO week in the prior year.
|
||||
|
||||
Useful for querying data for the entire comparison week.
|
||||
|
||||
Args:
|
||||
target_date: Any date within the target week
|
||||
|
||||
Returns:
|
||||
Tuple of (week_start, week_end) for the prior year's matching week
|
||||
"""
|
||||
iso_cal = target_date.isocalendar()
|
||||
prior_year = iso_cal.year - 1
|
||||
prior_week = iso_cal.week
|
||||
|
||||
# Handle edge case: if prior year doesn't have this week number
|
||||
# (can happen with week 53), fall back to last week of prior year
|
||||
try:
|
||||
# Find the first day of the target week in prior year
|
||||
# Week 1 day 1 of the prior year
|
||||
jan_1_prior = date(prior_year, 1, 1)
|
||||
jan_1_iso = jan_1_prior.isocalendar()
|
||||
|
||||
# Calculate days to add to get to the target week
|
||||
# First, get to week 1 day 1
|
||||
days_to_week_1 = (1 - jan_1_iso.weekday) % 7
|
||||
week_1_monday = jan_1_prior + timedelta(days=days_to_week_1)
|
||||
|
||||
# Adjust if Jan 1 is in the previous year's last week
|
||||
if jan_1_iso.week != 1:
|
||||
week_1_monday = jan_1_prior + timedelta(days=(7 - jan_1_prior.weekday()))
|
||||
|
||||
# Now add weeks to get to target week
|
||||
week_start = week_1_monday + timedelta(weeks=prior_week - 1)
|
||||
|
||||
# Verify we got the right week
|
||||
if week_start.isocalendar().week != prior_week:
|
||||
# Fallback: use last week of prior year
|
||||
dec_28_prior = date(prior_year, 12, 28) # Always in last week
|
||||
dec_28_iso = dec_28_prior.isocalendar()
|
||||
week_start = dec_28_prior - timedelta(days=dec_28_prior.weekday())
|
||||
|
||||
week_end = week_start + timedelta(days=6)
|
||||
return (week_start, week_end)
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
# Fallback to simpler calculation
|
||||
prior_date = get_prior_year_daily(target_date)
|
||||
week_start = prior_date - timedelta(days=prior_date.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
return (week_start, week_end)
|
||||
|
||||
|
||||
def get_prior_year_monthly(target_date: date) -> Tuple[date, date]:
|
||||
"""
|
||||
Get the start and end dates of the same month in the prior year.
|
||||
|
||||
Args:
|
||||
target_date: Any date within the target month
|
||||
|
||||
Returns:
|
||||
Tuple of (month_start, month_end) for the prior year's matching month
|
||||
"""
|
||||
prior_year = target_date.year - 1
|
||||
month = target_date.month
|
||||
|
||||
# First day of the month
|
||||
month_start = date(prior_year, month, 1)
|
||||
|
||||
# Last day of the month
|
||||
if month == 12:
|
||||
month_end = date(prior_year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
month_end = date(prior_year, month + 1, 1) - timedelta(days=1)
|
||||
|
||||
return (month_start, month_end)
|
||||
|
||||
|
||||
# SQL helper constants for use in queries
|
||||
SQL_PRIOR_YEAR_DAILY = "INTERVAL '364 days'" # For daily comparisons
|
||||
SQL_PRIOR_YEAR_OFFSET = 364 # Days offset for daily DOW alignment
|
||||
|
||||
|
||||
def get_comparison_info(target_date: date) -> dict:
|
||||
"""
|
||||
Get formatted comparison information for display.
|
||||
|
||||
Useful for showing users what date/week is being compared.
|
||||
|
||||
Args:
|
||||
target_date: The date being forecasted/analyzed
|
||||
|
||||
Returns:
|
||||
Dict with comparison details
|
||||
"""
|
||||
prior_daily = get_prior_year_daily(target_date)
|
||||
prior_week_year, prior_week_num = get_prior_year_weekly(target_date)
|
||||
|
||||
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
return {
|
||||
"target_date": target_date,
|
||||
"target_day": day_names[target_date.weekday()],
|
||||
"target_iso_week": target_date.isocalendar().week,
|
||||
"prior_year_date": prior_daily,
|
||||
"prior_year_day": day_names[prior_daily.weekday()], # Should match target_day
|
||||
"prior_year_iso_week": prior_week_num,
|
||||
"comparison_note": f"vs {day_names[prior_daily.weekday()]} {prior_daily.strftime('%d %b %Y')}"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue