Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
New widget type with four display modes -- current conditions, an hourly forecast strip, a multi-day forecast, and several cities' current day side by side -- backed by a pluggable provider registry (app/weather/, mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and NWS (US-only) both wired up now, Environment Canada documented as the next one to add given its more involved station/grid-lookup API. The calendar widget's existing embedded weather strip is untouched and still Open-Meteo-only; this lifts the same underlying icon-drawing primitives (now shared via app/weather_render.py, calendar_render.py still imports draw_weather_row unchanged) into a widget that can be placed and sized on its own. Icons are redrawn in the panel's actual ink colors (yellow sun/bolt, blue rain/snow) instead of flat black, and build_multi_city's icon/font sizing now scales with how many cities need to fit rather than the box's height alone -- both fixed after catching them via live browser verification, along with a mode-switch cache-shape crash and a mobile-width dialog overflow. New WeatherWidgetConfig table (migration 24), grid footprint, widget module, common.py fetch/cache helper, router endpoints (location set/ clear, city add/remove, preview), dialog template + JS, and full test coverage (providers, widget render, HTTP endpoints, migration replay). docs/widgets.md and CLAUDE.md's TODO updated accordingly.
This commit is contained in:
@@ -31,6 +31,7 @@ from ..models import (
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
@@ -574,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
||||
return result
|
||||
|
||||
|
||||
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
|
||||
|
||||
|
||||
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
|
||||
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
|
||||
weather widget (see app/widgets/weather.py) -- reads/writes
|
||||
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
|
||||
hourly/daily need a single configured location (city_latitude/
|
||||
city_longitude); multi_city needs cfg.cities. None if not configured
|
||||
yet, so render() falls back to a placeholder -- same convention as
|
||||
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
|
||||
button) bypasses the throttle entirely.
|
||||
|
||||
A single-location mode's fetch failure keeps the last-known cached
|
||||
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
|
||||
multi_city fetch fails per-city (like get_or_refresh_weather_for_
|
||||
widget's calendar-strip counterpart) so one broken city doesn't blank
|
||||
the others."""
|
||||
cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
if cfg.mode == "multi_city":
|
||||
if not cfg.cities:
|
||||
return None
|
||||
elif cfg.city_latitude is None or cfg.city_longitude is None:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
|
||||
return cfg.cached
|
||||
|
||||
if cfg.mode == "multi_city":
|
||||
previous = {c["label"]: c for c in (cfg.cached or [])}
|
||||
result = []
|
||||
for city in cfg.cities:
|
||||
try:
|
||||
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
|
||||
d = next(iter(today.values())) if today else previous.get(city["label"], {})
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||
d = previous.get(city["label"], {})
|
||||
result.append({
|
||||
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
|
||||
"category": d.get("category", "cloudy"),
|
||||
})
|
||||
else:
|
||||
try:
|
||||
if cfg.mode == "current":
|
||||
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
|
||||
elif cfg.mode == "hourly":
|
||||
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||
hours=HOURLY_FETCH_HOURS)
|
||||
else: # "daily"
|
||||
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||
cfg.daily_days)
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
|
||||
return cfg.cached
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.cached = result
|
||||
locked_cfg.checked_at = now
|
||||
return result
|
||||
|
||||
|
||||
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user