Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s

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:
2026-07-27 16:16:43 +00:00
parent 6118705c37
commit 52ebafab78
25 changed files with 2036 additions and 152 deletions
+64
View File
@@ -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