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>
161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
"""
|
|
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')}"
|
|
}
|