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
@@ -20,6 +20,7 @@ from app.models import (
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
WeatherWidgetConfig,
Widget,
)
@@ -80,6 +81,18 @@ def _add_text_widget(db_session) -> Widget:
return widget
def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="weather", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return widget
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG")
@@ -150,6 +163,73 @@ def test_config_save_updates_a_tasks_widget(client, db_session):
assert cfg.show_completed is True
def test_config_save_updates_a_weather_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "hourly", "weather_provider": "nws", "weather_units": "celsius",
"weather_hourly_interval_hours": "6", "weather_daily_days": "3"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "hourly"
assert cfg.provider == "nws"
assert cfg.units == "celsius"
assert cfg.hourly_interval_hours == 6
assert cfg.daily_days == 3
def test_config_save_ignores_an_invalid_weather_mode(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "not_a_real_mode"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.mode == "current" # unchanged -- invalid value silently ignored, same as calendar_view's own validation
def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db_session, monkeypatch):
"""Regression test: a widget's `cached` shape depends on its mode (a
single-temp dict for current, a list for hourly/multi_city, a dict
for daily). Switching modes without clearing the old cache used to
crash the very next preview -- get_or_refresh_weather_widget_data's
multi_city branch tried `c["label"]` against a leftover "current"-
mode dict, TypeError: string indices must be integers -- rather than
just triggering a fresh fetch shaped for the new mode."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current", cached={"temp": 70.0, "category": "clear"},
checked_at=1e15) # far in the future -- would still be "fresh" if not cleared
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"weather_mode": "multi_city"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cached is None
cfg.cities = [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
db_session.commit()
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: [
{"label": "Portland", "high": 75, "low": 55, "category": "clear"},
],
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
@@ -420,3 +500,130 @@ def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400
# --- weather: location/cities/preview ---------------------------------
def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6):
monkeypatch.setattr(
"app.routers.api_widgets.weather.geocode_city",
lambda name: {"label": label, "latitude": latitude, "longitude": longitude},
)
def test_weather_location_set_and_clear(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current")
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-location",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
assert resp.json()["city"]["label"] == "Portland, Oregon, United States"
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.city_label == "Portland, Oregon, United States"
assert cfg.city_latitude == 45.5
assert cfg.city_longitude == -122.6
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-location",
json={"name": None},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.city_label is None
assert cfg.city_latitude is None
def test_weather_location_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-location",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 400
def test_weather_widget_cities_add_and_remove(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="multi_city")
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cities == [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 400 # already on the list
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/remove",
json={"label": "Portland, Oregon, United States"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.cities == []
def test_weather_widget_cities_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
_mock_geocode(monkeypatch)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
json={"name": "Portland, OR"},
headers=csrf_headers(client),
)
assert resp.status_code == 400
def test_preview_weather_400s_before_anything_is_configured(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 400
def test_preview_weather_400s_before_any_city_for_multi_city_mode(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="multi_city")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 400
def test_preview_weather_renders_after_location_is_set(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current")
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
def test_preview_weather_400s_for_a_widget_that_is_not_weather(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 400