fix(weather): match prep behavior — serve cached weather regardless of age
The /api/journal/weather route was filtering out cache rows older than 24 hours via parse_weather_card_data, while journal_prep.py read the same rows raw without freshness checking. Result: the daily prep referenced "home" and "work" temperatures while the right-rail UI showed nothing — two surfaces, same backing data, inconsistent visibility. Two changes: 1. parse_weather_card_data no longer returns None for stale data. WeatherCard already exposes fetched_at and gracefully hides today_high / forecast fields when they're absent, so old data renders with whatever fields the cached forecast still covers. 2. The /weather route opportunistically schedules a background refresh for any cache row older than 4 hours. If the user's journal_config has lat/lon for that location_key, the refresh runs and the next page load gets fresh data; if no usable config, the refresh is a silent no-op and the stale cache is still served. This makes prep and UI consistent. It also self-heals over time — once locations are configured, stale caches get refreshed on the next page load instead of waiting indefinitely. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ The ambient endpoints read locations + temp_unit + topic preferences from the
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
@@ -222,12 +223,50 @@ async def _journal_temp_unit(user_id: int) -> str:
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
|
||||
|
||||
|
||||
def _is_stale(cache_row) -> bool:
|
||||
if cache_row is None or cache_row.fetched_at is None:
|
||||
return True
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
|
||||
return age > _STALE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
|
||||
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
|
||||
config has no usable lat/lon for a given location_key."""
|
||||
cfg = await _resolve_config(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
for key in stale_keys:
|
||||
loc = locations.get(key)
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
|
||||
|
||||
|
||||
@journal_bp.get("/weather")
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
|
||||
# Kick off a best-effort background refresh for stale rows so the next page
|
||||
# load gets fresh data; we still serve whatever's currently cached now.
|
||||
stale_keys = {row.location_key for row in rows if _is_stale(row)}
|
||||
if stale_keys:
|
||||
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
|
||||
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
|
||||
@@ -156,15 +156,13 @@ def parse_weather_card_data(
|
||||
) -> dict | None:
|
||||
"""
|
||||
Parse a WeatherCache row into the metadata.weather card schema.
|
||||
Returns None if the cache is stale (older than 24 hours) or unavailable.
|
||||
Returns None if the cache row is missing or unparseable. Stale data is
|
||||
returned as-is — the frontend uses `fetched_at` to convey freshness, and
|
||||
WeatherCard handles missing today/forecast fields gracefully.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
if cache_row is None or cache_row.fetched_at is None:
|
||||
return None
|
||||
|
||||
age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds()
|
||||
if age_seconds > 86400:
|
||||
if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json:
|
||||
return None
|
||||
|
||||
raw = cache_row.forecast_json or {}
|
||||
|
||||
Reference in New Issue
Block a user