diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 08:42:48 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 08:42:48 +0200 |
| commit | 0f5bd9dbded3c6b51731581cb466bad0cbd4c5d1 (patch) | |
| tree | 690312ba96772a09cf653fe9e2ee71a921a69e7c | |
| parent | b7692a3c33e6e66261c292d497a8cf1c9b8b07ba (diff) | |
| download | conky-theme-udt-0f5bd9dbded3c6b51731581cb466bad0cbd4c5d1.tar.gz conky-theme-udt-0f5bd9dbded3c6b51731581cb466bad0cbd4c5d1.zip | |
docs: add the weather widget implementation plan
Nine tasks, TDD throughout: the domain tables and parser get tests, the
card gets a screenshot.
The icon codepoints in the plan were each rendered and inspected rather
than derived. That caught U+E30A, which is present in the font and draws
a sun-with-rain, standing in for snow; the correct glyph is U+E31A. A
wrong-but-present codepoint fails this way, plausibly, which is why the
plan tells the implementer to look at the rendered sheet instead of
trusting a fontconfig query.
Task 6 and Task 8 both state which outcomes are acceptable when the OWM
key is still activating, so an inactive key blocks neither.
Paths are relative to the repo root rather than absolute: the first
version of this file hardcoded a home directory in eighteen places and
the pre-commit hook rejected it, correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | docs/superpowers/plans/2026-09-17-weather-widget.md | 1186 |
1 files changed, 1186 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-weather-widget.md b/docs/superpowers/plans/2026-09-17-weather-widget.md new file mode 100644 index 0000000..14c7044 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-weather-widget.md @@ -0,0 +1,1186 @@ +# Weather Widget Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One vertical dashboard card showing current weather on top and a sunrise-to-sunset arc at the bottom, fed by a cached OpenWeatherMap response. + +**Architecture:** A shell script curls OWM into `~/.cache/udt/weather.json` on conky's own `${execi}` schedule. `lib/weather.lua` parses that file and owns the domain tables (condition ids, Beaufort, compass); `widgets/weather.lua` draws the card from the parsed table. The dashboard never touches the network. + +**Tech Stack:** Lua 5.4 + Cairo (via conky's bindings), bash + curl for the fetch, `jq` only inside the fetch script. No JSON library: Lua patterns read the handful of fields the card draws. + +**Spec:** `docs/superpowers/specs/2026-09-17-weather-widget-design.md` + +--- + +## Critical platform facts + +Read these before touching anything. Each one cost time to learn on this project. + +- **A Lua error is a blank screen.** Conky reports a Lua fault on no stream. The `pcall` overlay in `dashboard.lua` is the only reason failures are visible. A parser that raises takes the whole dashboard down, so every parse path returns `nil` instead. +- **`conky_parse('${color3}')` returns an empty string.** Colours are parsed out of the file at `conky_config`. Widgets never read colours themselves; they use the `colors` table they are handed. +- **Never call `cairo_text_extents_t:create()` per call.** It leaks ~182KB per 5000 allocations and `collectgarbage()` does not reclaim it. `lib/card.lua` already owns one reused struct; use `card.measure()` and `card.advance()`. +- **`card.measure()` is ink width, `card.advance()` is cursor movement.** Stepping a cursor by ink width collapses spaces. Use `advance()` for runs of text, `measure()` only to centre or right-align. +- **`${execi}` fires even when it prints nothing** and even though `conky.text` is otherwise empty (verified 2026-09-17: a probe config ran the command twice over a 12s run at `execi 2`). This is what makes the fetch scheduling work. +- **Conky never rereads its config.** Any `conky.conf.in` change needs UDT's `install.sh` to re-render and restart conky. +- **`Inconsolata Nerd Font` has all the glyphs used here** (Weather Icons U+E3xx plus U+E34C/U+E34D). Verified by rendering. A missing glyph would be an invisible blank, not an error. +- **The API key may still be inactive.** A new OWM key returns `401 Invalid API key` for minutes to hours after creation. As of this plan's writing the key in `~/.config/udt/weather.env` still returns 401, which is expected and not a bug in this code. Every task below is testable without a working key; Task 8 is where a live key finally matters. + +--- + +## File structure + +| Path | Responsibility | +|---|---| +| `bin/weather-fetch.sh` | Fetch and cache. Knows the API and the env file; knows nothing about drawing. | +| `lib/weather.lua` | Parse the cache; condition/Beaufort/compass tables; sun position maths. Pure functions over strings and numbers. | +| `widgets/weather.lua` | Draw the card. Knows the layout bands; knows nothing about the API. | +| `test/test_weather.lua` | Checks for everything in `lib/weather.lua`. | +| `test/fixtures/weather.json` | A real-shaped OWM response with placeholder key and city. | +| `test/fixtures/weather_truncated.json` | A half-written response, for the atomicity failure mode. | +| `weather.env.example` | Placeholder config, committed. The real file is gitignored. | + +Modified: `conky.conf.in` (the `execi` line), `dashboard.lua` (one layout row), `README.md`, and UDT's `install.sh` (symlink `bin/`, silence its conky restart). + +--- + +## Task 1: Condition id to icon + +**Files:** +- Create: `lib/weather.lua` +- Create: `test/test_weather.lua` + +- [ ] **Step 1: Write the failing test** + +Create `test/test_weather.lua`: + +```lua +-- Checks for lib/weather.lua. +-- Run from the repo root: lua test/test_weather.lua +-- The domain tables are what silently misreport when a boundary is off by one, +-- so every range boundary gets an assertion. The card itself is verified by +-- screenshot. + +package.path = './?.lua;' .. package.path +local weather = require 'lib.weather' + +-- === Condition id to icon ================================================= +-- OWM ids group by hundreds, but the boundaries are not round numbers: the +-- ranges come from the polybar script that ran against this API for years. +-- Each assertion pairs the last id in a range with the first id of the next, +-- which is where an off-by-one would hide. +local DAY, NIGHT = 1000, 2000 -- timestamps either side of the sun, set below + +local function icon(id, now) + return weather.icon(id, now, 1500, 1900) -- sunrise 1500, sunset 1900 +end + +-- Thunderstorm: everything up to 232. +assert(icon(200, DAY) == weather.ICON.thunder_day, 'id 200 day') +assert(icon(232, DAY) == weather.ICON.thunder_day, 'id 232 is still thunder') +assert(icon(232, NIGHT) == weather.ICON.thunder_night, 'id 232 night') + +-- Light drizzle: 233..311. +assert(icon(233, DAY) == weather.ICON.drizzle_day, 'id 233 leaves thunder') +assert(icon(311, DAY) == weather.ICON.drizzle_day, 'id 311 is still light drizzle') + +-- Heavy drizzle: 312..321. +assert(icon(312, DAY) == weather.ICON.drizzle_heavy_day, 'id 312 is heavy drizzle') +assert(icon(321, DAY) == weather.ICON.drizzle_heavy_day, 'id 321 is still heavy drizzle') + +-- Rain: 322..531. +assert(icon(322, DAY) == weather.ICON.rain_day, 'id 322 is rain') +assert(icon(531, DAY) == weather.ICON.rain_day, 'id 531 is still rain') + +-- Snow: 532..622. One icon, no day/night variant. +assert(icon(600, DAY) == weather.ICON.snow, 'id 600 is snow') +assert(icon(622, NIGHT) == weather.ICON.snow, 'snow is the same at night') + +-- Fog: 623..771. +assert(icon(741, DAY) == weather.ICON.fog, 'id 741 is fog') +assert(icon(771, DAY) == weather.ICON.fog, 'id 771 is still fog') + +-- Tornado is a single id, not a range. +assert(icon(781, DAY) == weather.ICON.tornado, 'id 781 is tornado') + +-- Clear and few clouds each have a day and a night face. +assert(icon(800, DAY) == weather.ICON.clear_day, 'id 800 day is the sun') +assert(icon(800, NIGHT) == weather.ICON.clear_night, 'id 800 night is the moon') +assert(icon(801, DAY) == weather.ICON.few_day, 'id 801 day') +assert(icon(801, NIGHT) == weather.ICON.few_night, 'id 801 night') + +-- Overcast: 802..804, no night variant. +assert(icon(804, DAY) == weather.ICON.overcast, 'id 804 is overcast') + +-- Anything outside the known ids must be visibly wrong, not silently sunny. +assert(icon(999, DAY) == weather.ICON.unknown, 'unknown id gets the error glyph') +assert(weather.icon(nil, DAY, 1500, 1900) == weather.ICON.unknown, 'nil id') + +print('test_weather: all assertions passed') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `lua test/test_weather.lua` +Expected: FAIL with `module 'lib.weather' not found` + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/weather.lua`: + +```lua +-- OpenWeatherMap domain knowledge and cache parsing. +-- +-- Separate from lib/data.lua: that reads /proc and /sys, this reads a cached +-- HTTP response and carries its own tables. Same testable shape though, every +-- function takes a string or numbers and returns a value, so the tests need no +-- filesystem and no network. +-- +-- Nothing here raises. A Lua error in this project is a blank screen, so every +-- parse path returns nil and lets the widget draw its "no data" state. + +local M = {} + +-- Nerd Font codepoints, all present in Inconsolata Nerd Font (card.FONT_MONO). +-- Verified by rendering the glyphs and looking at them, not by fontconfig +-- alone: a missing glyph draws as an invisible blank rather than an error. +M.ICON = { + thunder_day = '\u{E30F}', + thunder_night = '\u{E32A}', + drizzle_day = '\u{E306}', + drizzle_night = '\u{E326}', + drizzle_heavy_day = '\u{E308}', + drizzle_heavy_night = '\u{E325}', + rain_day = '\u{E308}', + rain_night = '\u{E325}', + snow = '\u{E31A}', + fog = '\u{E313}', + tornado = '\u{E351}', + clear_day = '\u{E30D}', + clear_night = '\u{E32B}', + few_day = '\u{E302}', + few_night = '\u{E379}', + overcast = '\u{E312}', + unknown = '\u{E374}', + sunrise = '\u{E34C}', + sunset = '\u{E34D}', +} + +-- Condition id to icon, by upper bound. Ported from the polybar script's +-- accumulated knowledge; the ranges are not round hundreds. +-- `now`, `sunrise` and `sunset` are unix timestamps and pick the day or night +-- face for the conditions that have both. +function M.icon(id, now, sunrise, sunset) + if type(id) ~= 'number' then return M.ICON.unknown end + local day = true + if sunrise and sunset then day = (now >= sunrise and now <= sunset) end + local function pick(d, n) return day and d or n end + + if id <= 232 then return pick(M.ICON.thunder_day, M.ICON.thunder_night) + elseif id <= 311 then return pick(M.ICON.drizzle_day, M.ICON.drizzle_night) + elseif id <= 321 then return pick(M.ICON.drizzle_heavy_day, M.ICON.drizzle_heavy_night) + elseif id <= 531 then return pick(M.ICON.rain_day, M.ICON.rain_night) + elseif id <= 622 then return M.ICON.snow + elseif id <= 771 then return M.ICON.fog + elseif id == 781 then return M.ICON.tornado + elseif id == 800 then return pick(M.ICON.clear_day, M.ICON.clear_night) + elseif id == 801 then return pick(M.ICON.few_day, M.ICON.few_night) + elseif id <= 804 then return M.ICON.overcast + end + return M.ICON.unknown +end + +return M +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `lua test/test_weather.lua` +Expected: PASS, printing `test_weather: all assertions passed` + +- [ ] **Step 5: Verify the glyphs actually render** + +The test proves the mapping, not that the codepoints are real glyphs. Render them once and look: + +```bash +f=$(fc-match -f '%{file}' 'Inconsolata Nerd Font') +magick -background '#0d1b26' -fill '#c5d8e3' -font "$f" -pointsize 54 \ + label:$' ' \ + /tmp/wicons.png +``` + +Open `/tmp/wicons.png`. Expected, in order: thunder day, thunder night, +drizzle day, drizzle night, rain day, rain night, snow (a cloud with +snowflakes, no sun), fog, tornado, clear day, clear night (a bare crescent), +few clouds day, few clouds night, overcast, an "N/A" box, sunrise, sunset. + +All seventeen were rendered and inspected while this plan was written, so they +are known good. Look anyway: a wrong-but-present codepoint draws a plausible +neighbouring glyph rather than failing, which is how `E30A` was caught standing +in for snow with a sun-and-rain icon. A blank means the codepoint is absent +entirely. + +- [ ] **Step 6: Commit** + +```bash +git add lib/weather.lua test/test_weather.lua +git commit -m "feat: add OWM condition id to icon mapping" +``` + +--- + +## Task 2: Wind, Beaufort and compass + +**Files:** +- Modify: `lib/weather.lua` +- Modify: `test/test_weather.lua` + +- [ ] **Step 1: Write the failing test** + +Insert into `test/test_weather.lua`, immediately before the final `print` line: + +```lua +-- === Wind ================================================================= +-- OWM metric gives m/s; the card shows km/h. The conversion is where a wrong +-- factor would look plausible but read 3.6x off. +assert(weather.kmh(10) == 36, '10 m/s is 36 km/h') +assert(weather.kmh(0) == 0, 'calm') +assert(weather.kmh(nil) == nil, 'missing wind speed stays missing') + +-- Beaufort thresholds, in km/h, from the polybar script. Each assertion sits +-- on a boundary and just past it, which is where an inclusive/exclusive +-- mistake hides. +assert(weather.beaufort(0) == 0, 'calm is force 0') +assert(weather.beaufort(1) == 0, '1 km/h is still force 0') +assert(weather.beaufort(2) == 1, 'just over 1 is force 1') +assert(weather.beaufort(5) == 1, '5 is still force 1') +assert(weather.beaufort(6) == 2, 'just over 5 is force 2') +assert(weather.beaufort(11) == 2, '11 is still force 2') +assert(weather.beaufort(12) == 3, 'just over 11 is force 3') +assert(weather.beaufort(19) == 3, '19 is still force 3') +assert(weather.beaufort(28) == 4, '28 is force 4') +assert(weather.beaufort(38) == 5, '38 is force 5') +assert(weather.beaufort(49) == 6, '49 is force 6') +assert(weather.beaufort(61) == 7, '61 is force 7') +assert(weather.beaufort(74) == 8, '74 is force 8') +assert(weather.beaufort(88) == 9, '88 is force 9') +assert(weather.beaufort(102) == 10, '102 is force 10') +assert(weather.beaufort(117) == 11, '117 is force 11') +assert(weather.beaufort(118) == 12, 'over 117 is hurricane force') + +-- Compass: eight points, each spanning 45 degrees, offset by half a step so +-- north straddles 0 rather than starting at it. The wraparound is the case +-- that a naive floor(deg/45) gets wrong. +assert(weather.compass(0) == 'N', '0 is north') +assert(weather.compass(360) == 'N', '360 wraps to north') +assert(weather.compass(11) == 'N', 'just under the NE boundary') +assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north') +assert(weather.compass(45) == 'NE', '45 is northeast') +assert(weather.compass(90) == 'E', '90 is east') +assert(weather.compass(135) == 'SE', '135 is southeast') +assert(weather.compass(180) == 'S', '180 is south') +assert(weather.compass(225) == 'SW', '225 is southwest') +assert(weather.compass(270) == 'W', '270 is west') +assert(weather.compass(315) == 'NW', '315 is northwest') +assert(weather.compass(nil) == nil, 'missing direction stays missing') + +-- The arrow points where the wind is going, and OWM reports where it comes +-- from, so a north wind draws a downward arrow. +assert(weather.arrow(0) == '\u{2193}', 'a north wind blows southward') +assert(weather.arrow(180) == '\u{2191}', 'a south wind blows northward') +assert(weather.arrow(nil) == '', 'no direction draws nothing') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `lua test/test_weather.lua` +Expected: FAIL with `attempt to call a nil value (field 'kmh')` + +- [ ] **Step 3: Write minimal implementation** + +Add to `lib/weather.lua`, before the final `return M`: + +```lua +-- OWM's metric units report wind in m/s. The card shows km/h. +function M.kmh(ms) + if type(ms) ~= 'number' then return nil end + return ms * 3.6 +end + +-- Beaufort force from km/h. Upper bounds carried over from the polybar +-- script; a value equal to a bound stays in the lower force. +local BEAUFORT = { 1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117 } + +function M.beaufort(kmh) + if type(kmh) ~= 'number' then return nil end + for force, bound in ipairs(BEAUFORT) do + if kmh <= bound then return force - 1 end + end + return 12 +end + +-- Eight compass points. The half-step offset is what makes north straddle +-- zero: without it, 350 degrees would land in NW and 10 in NE, leaving north +-- with only half its arc. +local POINTS = { 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' } + +function M.compass(deg) + if type(deg) ~= 'number' then return nil end + local i = math.floor(((deg % 360) + 22.5) / 45) % 8 + return POINTS[i + 1] +end + +-- Arrow glyphs, in the same order as POINTS but rotated half a turn: OWM +-- reports the direction the wind comes FROM, and an arrow reads as the +-- direction it goes TO. +local ARROWS = { '\u{2193}', '\u{2199}', '\u{2190}', '\u{2196}', + '\u{2191}', '\u{2197}', '\u{2192}', '\u{2198}' } + +function M.arrow(deg) + if type(deg) ~= 'number' then return '' end + local i = math.floor(((deg % 360) + 22.5) / 45) % 8 + return ARROWS[i + 1] +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `lua test/test_weather.lua` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/weather.lua test/test_weather.lua +git commit -m "feat: add wind conversion, Beaufort and compass tables" +``` + +--- + +## Task 3: Sun position along the arc + +**Files:** +- Modify: `lib/weather.lua` +- Modify: `test/test_weather.lua` + +- [ ] **Step 1: Write the failing test** + +Insert into `test/test_weather.lua`, immediately before the final `print` line: + +```lua +-- === Sun position ========================================================= +-- t is the fraction of daylight elapsed, and the widget uses it to place the +-- dot along the arc. Sunrise 1000, sunset 2000, so midday is 1500. +assert(weather.sun_t(1000, 1000, 2000) == 0, 'at sunrise t is 0') +assert(weather.sun_t(1500, 1000, 2000) == 0.5, 'at midday t is half') +assert(weather.sun_t(2000, 1000, 2000) == 1, 'at sunset t is 1') + +-- OWM reports today's sunrise and sunset, so between midnight and sunrise the +-- numerator is negative. The clamp is the whole handling; without it the dot +-- would be drawn off the left end of the arc. +assert(weather.sun_t(500, 1000, 2000) == 0, 'before dawn clamps to 0') +assert(weather.sun_t(9999, 1000, 2000) == 1, 'after dusk clamps to 1') + +-- Degenerate input must not divide by zero. +assert(weather.sun_t(1500, 2000, 2000) == 0, 'zero-length day gives 0, not nan') +assert(weather.sun_t(nil, 1000, 2000) == nil, 'missing now gives nil') +assert(weather.sun_t(1500, nil, 2000) == nil, 'missing sunrise gives nil') + +-- is_day drives both the icon face and the dot's colour: a dot parked at the +-- end of the arc must not read as a sun that is still up. +assert(weather.is_day(1500, 1000, 2000) == true, 'midday is day') +assert(weather.is_day(1000, 1000, 2000) == true, 'sunrise counts as day') +assert(weather.is_day(2000, 1000, 2000) == true, 'sunset counts as day') +assert(weather.is_day(500, 1000, 2000) == false, 'before dawn is night') +assert(weather.is_day(2500, 1000, 2000) == false, 'after dusk is night') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `lua test/test_weather.lua` +Expected: FAIL with `attempt to call a nil value (field 'sun_t')` + +- [ ] **Step 3: Write minimal implementation** + +Add to `lib/weather.lua`, before the final `return M`: + +```lua +-- Fraction of daylight elapsed, clamped to 0..1. +-- +-- OWM returns TODAY's sunrise and sunset, so before dawn this is negative and +-- after dusk it is over 1. Clamping parks the dot at the corresponding end of +-- the arc, which is the cheap correct-enough behaviour; multi-day astronomy +-- buys nothing for a dot on a curve. +function M.sun_t(now, sunrise, sunset) + if type(now) ~= 'number' or type(sunrise) ~= 'number' + or type(sunset) ~= 'number' then return nil end + local span = sunset - sunrise + if span <= 0 then return 0 end -- polar day/night or bad data: no division + local t = (now - sunrise) / span + if t < 0 then return 0 elseif t > 1 then return 1 end + return t +end + +-- Whether the sun is up. Inclusive at both ends, matching the polybar script's +-- comparison, so the instant of sunrise draws the day face. +function M.is_day(now, sunrise, sunset) + if type(now) ~= 'number' or type(sunrise) ~= 'number' + or type(sunset) ~= 'number' then return true end + return now >= sunrise and now <= sunset +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `lua test/test_weather.lua` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add lib/weather.lua test/test_weather.lua +git commit -m "feat: add sun position along the daylight arc" +``` + +--- + +## Task 4: Parse the cached response + +**Files:** +- Modify: `lib/weather.lua` +- Modify: `test/test_weather.lua` +- Create: `test/fixtures/weather.json` +- Create: `test/fixtures/weather_truncated.json` + +- [ ] **Step 1: Create the fixtures** + +Create `test/fixtures/weather.json`. This is a real OWM response shape with the +location replaced by a placeholder, since no real location belongs in committed +files: + +```json +{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"base":"stations","main":{"temp":18.34,"feels_like":18.11,"temp_min":16.67,"temp_max":19.44,"pressure":1014,"humidity":72},"visibility":10000,"wind":{"speed":4.12,"deg":230},"clouds":{"all":75},"dt":1789625000,"sys":{"type":2,"id":2004688,"country":"XX","sunrise":1789600000,"sunset":1789646000},"timezone":7200,"id":1,"name":"Example City","cod":200} +``` + +Create `test/fixtures/weather_truncated.json`, a response cut mid-write, which +is what an unprotected cache would hand the parser: + +```json +{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","descrip +``` + +- [ ] **Step 2: Write the failing test** + +Insert into `test/test_weather.lua`, immediately before the final `print` line: + +```lua +-- === Parsing the cache ==================================================== +-- The parser reads scalars by key with Lua patterns rather than pulling in a +-- JSON library: the response is flat and known, and six numbers do not justify +-- a dependency. Caching the whole response still costs nothing, since a field +-- added later is already on disk. +local function read(path) + local f = assert(io.open(path, 'r')) + local s = f:read('*a') + f:close() + return s +end + +local w = weather.parse(read('test/fixtures/weather.json')) +assert(w, 'the fixture must parse') +assert(w.id == 501, 'condition id, got ' .. tostring(w.id)) +assert(w.description == 'moderate rain', 'description, got ' .. tostring(w.description)) +assert(w.temp == 18.34, 'temp, got ' .. tostring(w.temp)) +assert(w.feels_like == 18.11, 'feels like, got ' .. tostring(w.feels_like)) +assert(w.humidity == 72, 'humidity, got ' .. tostring(w.humidity)) +assert(w.wind_speed == 4.12, 'wind m/s, got ' .. tostring(w.wind_speed)) +assert(w.wind_deg == 230, 'wind direction, got ' .. tostring(w.wind_deg)) +assert(w.sunrise == 1789600000, 'sunrise, got ' .. tostring(w.sunrise)) +assert(w.sunset == 1789646000, 'sunset, got ' .. tostring(w.sunset)) +assert(w.city == 'Example City', 'city, got ' .. tostring(w.city)) +assert(w.dt == 1789625000, 'observation time, got ' .. tostring(w.dt)) + +-- temp comes before feels_like in the response and both live under "main", +-- so a lazy pattern would read one for the other. They differ in the fixture +-- precisely so this is checkable. +assert(w.temp ~= w.feels_like, 'temp and feels_like must not collapse') + +-- Failure modes all return nil rather than raising: a Lua error here is a +-- blank dashboard, not a message. +assert(weather.parse(read('test/fixtures/weather_truncated.json')) == nil, + 'a truncated response must give nil') +assert(weather.parse('') == nil, 'empty input gives nil') +assert(weather.parse('not json at all') == nil, 'garbage gives nil') +assert(weather.parse(nil) == nil, 'nil input gives nil') + +-- An error body carries cod 401 and no weather. It must not parse as data. +assert(weather.parse('{"cod":401,"message":"Invalid API key."}') == nil, + 'an API error body must give nil') +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `lua test/test_weather.lua` +Expected: FAIL with `attempt to call a nil value (field 'parse')` + +- [ ] **Step 4: Write minimal implementation** + +Add to `lib/weather.lua`, before the final `return M`: + +```lua +-- Parse the cached OWM response. +-- +-- Lua patterns, not a JSON library: the current-weather response is flat and +-- its shape is known, so six scalars do not justify a dependency. Returns nil +-- on anything unreadable, never an error. +function M.parse(src) + if type(src) ~= 'string' or src == '' then return nil end + + -- Scalars are matched inside their own object where the key would otherwise + -- be ambiguous. "temp" appears as a prefix of "temp_min" and "temp_max", so + -- it is anchored to the character that follows it. + local function num(pat) + return tonumber(src:match(pat)) + end + + local main = src:match('"main"%s*:%s*(%b{})') or '' + local wind = src:match('"wind"%s*:%s*(%b{})') or '' + local sys = src:match('"sys"%s*:%s*(%b{})') or '' + local cond = src:match('"weather"%s*:%s*%[%s*(%b{})') or '' + + local w = { + id = tonumber(cond:match('"id"%s*:%s*(%-?%d+)')), + description = cond:match('"description"%s*:%s*"([^"]*)"'), + temp = tonumber(main:match('"temp"%s*:%s*(%-?[%d%.]+)')), + feels_like = tonumber(main:match('"feels_like"%s*:%s*(%-?[%d%.]+)')), + humidity = tonumber(main:match('"humidity"%s*:%s*(%d+)')), + wind_speed = tonumber(wind:match('"speed"%s*:%s*([%d%.]+)')), + wind_deg = tonumber(wind:match('"deg"%s*:%s*(%d+)')), + sunrise = tonumber(sys:match('"sunrise"%s*:%s*(%d+)')), + sunset = tonumber(sys:match('"sunset"%s*:%s*(%d+)')), + city = src:match('"name"%s*:%s*"([^"]*)"'), + dt = num('"dt"%s*:%s*(%d+)'), + } + + -- Without these the card has nothing to draw, and a partial card is worse + -- than an honest "no data". An API error body fails here too: it carries a + -- cod and a message, no weather. + if not (w.id and w.temp and w.sunrise and w.sunset) then return nil end + return w +end +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `lua test/test_weather.lua` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add lib/weather.lua test/test_weather.lua test/fixtures/weather.json test/fixtures/weather_truncated.json +git commit -m "feat: parse the cached OWM response" +``` + +--- + +## Task 5: Staleness + +**Files:** +- Modify: `lib/weather.lua` +- Modify: `test/test_weather.lua` + +- [ ] **Step 1: Write the failing test** + +Insert into `test/test_weather.lua`, immediately before the final `print` line: + +```lua +-- === Staleness ============================================================ +-- 45 minutes is three missed fetches at the 15-minute interval, so one +-- transient failure does not flag the card. +assert(weather.is_stale(1000, 1000) == false, 'a fresh reading is not stale') +assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45 min is the boundary') +assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'past 45 min is stale') +assert(weather.is_stale(nil, 1000) == true, 'no observation time counts as stale') + +-- The age string is what the card prints beside the city, so it must be short. +assert(weather.age_str(1000, 1000 + 90) == '1m', 'ninety seconds reads as 1m') +assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h') +assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours') +assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `lua test/test_weather.lua` +Expected: FAIL with `attempt to call a nil value (field 'is_stale')` + +- [ ] **Step 3: Write minimal implementation** + +Add to `lib/weather.lua`, before the final `return M`: + +```lua +-- Three missed fetches at the 15-minute interval. +M.STALE_AFTER = 45 * 60 + +function M.is_stale(dt, now) + if type(dt) ~= 'number' then return true end + return (now - dt) > M.STALE_AFTER +end + +-- Short age for the marker beside the city: "12m", "3h", "2d". +function M.age_str(dt, now) + if type(dt) ~= 'number' then return '?' end + local s = now - dt + if s < 3600 then return math.floor(s / 60) .. 'm' end + if s < 86400 then return math.floor(s / 3600) .. 'h' end + return math.floor(s / 86400) .. 'd' +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `lua test/test_weather.lua` +Expected: PASS + +- [ ] **Step 5: Run the whole suite** + +Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` +Expected: three "all assertions passed" lines + +- [ ] **Step 6: Commit** + +```bash +git add lib/weather.lua test/test_weather.lua +git commit -m "feat: add cache staleness classification" +``` + +--- + +## Task 6: The fetch script + +**Files:** +- Create: `bin/weather-fetch.sh` +- Create: `weather.env.example` + +- [ ] **Step 1: Write the example config** + +Create `weather.env.example`: + +```bash +# Copy to ~/.config/udt/weather.env and fill in. That path is outside this +# repo on purpose: it holds a key, and anything committed is potentially +# public. chmod 600 it. +# +# Get a key at https://openweathermap.org/api. A new key returns +# "401 Invalid API key" for minutes to hours after creation; that is the API +# activating it, not a mistake in this file. + +KEY=your_32_character_api_key_here +CITY=Your City +COUNTRY=XX +UNITS=metric +``` + +- [ ] **Step 2: Write the fetch script** + +Create `bin/weather-fetch.sh`: + +```bash +#!/bin/bash +# Fetch current weather into a cache file. Run from conky's ${execi}, so it +# prints nothing on success and never blocks the dashboard: the widget only +# ever reads the cache. +# +# Exits non-zero with a message on stderr when it cannot fetch, and leaves any +# existing cache untouched rather than replacing good data with an error. + +set -u + +ENV_FILE="${WEATHER_ENV:-$HOME/.config/udt/weather.env}" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" +CACHE="$CACHE_DIR/weather.json" + +if [ ! -r "$ENV_FILE" ]; then + echo "weather-fetch: no $ENV_FILE (copy weather.env.example)" >&2 + exit 1 +fi + +# shellcheck source=/dev/null +set -a; . "$ENV_FILE"; set +a + +if [ -z "${KEY:-}" ]; then + echo "weather-fetch: KEY is empty in $ENV_FILE" >&2 + exit 1 +fi + +mkdir -p "$CACHE_DIR" + +# The temp file sits in the same directory as the target, because rename is +# only atomic within a filesystem. The widget reads this cache on its own 2s +# cadence, so a fetch killed mid-write would otherwise hand it half a response. +TMP="$CACHE.tmp.$$" +trap 'rm -f "$TMP"' EXIT + +URL="https://api.openweathermap.org/data/2.5/weather" +if ! curl -fsS --max-time 15 --get "$URL" \ + --data-urlencode "appid=$KEY" \ + --data-urlencode "units=${UNITS:-metric}" \ + --data-urlencode "lang=${LANG_CODE:-en}" \ + --data-urlencode "q=${CITY},${COUNTRY}" \ + -o "$TMP" 2>/dev/null; then + echo "weather-fetch: request failed" >&2 + exit 1 +fi + +# A bad key returns HTTP 200 with a JSON error body, so the status code alone +# proves nothing. This check is carried over from the polybar script, which +# learned it the same way. +if [ "$(jq -r '.cod // empty' "$TMP" 2>/dev/null)" != "200" ]; then + echo "weather-fetch: API error: $(jq -r '.message // "unknown"' "$TMP" 2>/dev/null)" >&2 + exit 1 +fi + +mv -f "$TMP" "$CACHE" +``` + +- [ ] **Step 3: Make it executable and check it with shellcheck** + +```bash +# from the repo root +chmod +x bin/weather-fetch.sh +shellcheck bin/weather-fetch.sh +``` + +Expected: no output. If `shellcheck` is not installed, skip it. + +- [ ] **Step 4: Verify the three failure paths** + +Each must fail loudly and leave the cache alone. + +```bash +# from the repo root +# Missing env file +WEATHER_ENV=/nonexistent ./bin/weather-fetch.sh; echo "exit: $?" +``` +Expected: `weather-fetch: no /nonexistent (copy weather.env.example)` and `exit: 1` + +```bash +# Empty key +printf 'KEY=\nCITY=X\nCOUNTRY=XX\nUNITS=metric\n' > /tmp/empty.env +WEATHER_ENV=/tmp/empty.env ./bin/weather-fetch.sh; echo "exit: $?" +``` +Expected: `weather-fetch: KEY is empty in /tmp/empty.env` and `exit: 1` + +```bash +# Bad key: HTTP 200 with an error body, the case the .cod check exists for +printf 'KEY=%s\nCITY=London\nCOUNTRY=GB\nUNITS=metric\n' \ + 00000000000000000000000000000000 > /tmp/bad.env +WEATHER_ENV=/tmp/bad.env ./bin/weather-fetch.sh; echo "exit: $?" +ls ~/.cache/udt/weather.json 2>/dev/null || echo "cache correctly not created" +rm -f /tmp/empty.env /tmp/bad.env +``` +Expected: an `API error` message, `exit: 1`, and no cache file written. + +- [ ] **Step 5: Verify the real key, which may not be active yet** + +```bash +./bin/weather-fetch.sh; echo "exit: $?" +``` + +Two acceptable outcomes: +- `exit: 0` and `~/.cache/udt/weather.json` exists: the key is live, continue. +- `weather-fetch: API error: Invalid API key.` and `exit: 1`: the key is still + activating. **This is not a defect in this task.** Confirm the script's + behaviour is right (it printed a message and wrote no cache) and continue; + Task 8 retries with a live key. + +- [ ] **Step 6: Commit** + +```bash +git add bin/weather-fetch.sh weather.env.example +git commit -m "feat: add the weather fetch script" +``` + +--- + +## Task 7: The card + +**Files:** +- Create: `widgets/weather.lua` +- Modify: `dashboard.lua` (the `layout` table) +- Modify: `conky.conf.in` (the `conky.text` block) + +- [ ] **Step 1: Write the widget** + +Create `widgets/weather.lua`: + +```lua +-- Weather: current conditions over a sunrise-to-sunset arc. +-- +-- Shape follows idea1.png, which splits this across two cards; this merges +-- them into one vertical cell. Every colour comes from the palette, every size +-- derives from the rect, so the card composes on either monitor. + +local card = require 'lib.card' +local weather = require 'lib.weather' + +local M = {} + +local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/weather.json' + +-- Draw the card chrome with a message in it. Used for every no-data state, so +-- the dashboard keeps its shape instead of showing an empty cell, which is +-- indistinguishable from a crashed widget. +local function draw_notice(cr, inner, colors, line1, line2) + card.font(cr, card.FONT_UI, 15, true) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + 24, line1) + card.font(cr, card.FONT_MONO, 12, false) + card.text(cr, inner.x, inner.y + 48, line2) +end + +-- The daylight arc: a curve, its baseline, the sun, and the two times. +-- +-- The dot is placed by evaluating the curve at t rather than by computing a +-- point on a circle: the curve IS the path, so evaluating it keeps the dot on +-- the arc if the control points are ever adjusted. +local function draw_arc(cr, x, y, w, h, colors, w_data, now) + local pad = 4 + local x0, x1 = x + pad, x + w - pad + -- Room under the curve for the time labels. + local base = y + h - 20 + local top = y + 6 + + -- Cubic Bezier control points, chosen so the curve peaks near the middle at + -- about two thirds of the band height. + local p0 = { x0, base } + local c1 = { x0 + (x1 - x0) * 0.22, top - 10 } + local c2 = { x1 - (x1 - x0) * 0.22, top - 10 } + local p3 = { x1, base } + + -- The baseline: the horizon the sun rises from and sets into. + card.rgba(cr, colors.rule, 0.6) + cairo_set_line_width(cr, 1) + cairo_move_to(cr, x0, base) + cairo_line_to(cr, x1, base) + cairo_stroke(cr) + + -- The arc itself. + card.rgba(cr, colors.rule, 0.9) + cairo_set_line_width(cr, 1.5) + cairo_move_to(cr, p0[1], p0[2]) + cairo_curve_to(cr, c1[1], c1[2], c2[1], c2[2], p3[1], p3[2]) + cairo_stroke(cr) + + local t = weather.sun_t(now, w_data.sunrise, w_data.sunset) or 0 + local day = weather.is_day(now, w_data.sunrise, w_data.sunset) + + -- Evaluate the cubic at t. + local function bez(a, b, c, d) + local u = 1 - t + return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d + end + local sx = bez(p0[1], c1[1], c2[1], p3[1]) + local sy = bez(p0[2], c1[2], c2[2], p3[2]) + + -- At night the dot is parked at an end, so it takes the dim colour: a bright + -- dot sitting on the horizon would read as a sun that is still up. + card.rgba(cr, day and colors.body or colors.label, 1) + cairo_arc(cr, sx, sy, 5, 0, math.pi * 2) + cairo_fill(cr) + + -- The times, each behind its own glyph, at the ends of the arc. + card.font(cr, card.FONT_MONO, 12, false) + card.rgba(cr, colors.label) + local ty = y + h - 2 + card.text(cr, x0, ty, + weather.ICON.sunrise .. ' ' .. os.date('%H:%M', w_data.sunrise)) + card.text_right(cr, x1, ty, + weather.ICON.sunset .. ' ' .. os.date('%H:%M', w_data.sunset)) +end + +function M.draw(cr, rect, colors) + local inner = card.card(cr, rect, colors) + local now = os.time() + + local src = nil + local f = io.open(CACHE, 'r') + if f then src = f:read('*a'); f:close() end + + local w = weather.parse(src) + if not w then + if io.open(os.getenv('HOME') .. '/.config/udt/weather.env', 'r') then + draw_notice(cr, inner, colors, 'no weather data', 'waiting for first fetch') + else + draw_notice(cr, inner, colors, 'no weather data', 'set ~/.config/udt/weather.env') + end + return + end + + local y = inner.y + + -- Header: condition glyph, then the temperature with the city beneath it. + local glyph = weather.icon(w.id, now, w.sunrise, w.sunset) + card.font(cr, card.FONT_MONO, 36, false) + card.rgba(cr, colors.highlight) + card.text(cr, inner.x, y + 34, glyph) + local gx = inner.x + card.advance(cr, glyph) + 14 + + card.font(cr, card.FONT_HEAVY, 44, false) + card.rgba(cr, colors.body) + card.text(cr, gx, y + 38, string.format('%d\u{00B0}', math.floor(w.temp + 0.5))) + + card.font(cr, card.FONT_MONO, 11, false) + card.rgba(cr, colors.label) + local city = (w.city or ''):upper() + if weather.is_stale(w.dt, now) then + city = city .. ' stale ' .. weather.age_str(w.dt, now) + end + card.text(cr, gx, y + 56, city) + + -- Condition, in OWM's own words, first letter capitalised. + card.font(cr, card.FONT_UI, 13, false) + card.rgba(cr, colors.body) + local desc = w.description or '' + desc = desc:sub(1, 1):upper() .. desc:sub(2) + card.text(cr, inner.x, y + 84, desc) + + -- Rule. + local ry = y + 100 + card.rgba(cr, colors.rule, 0.8) + cairo_set_line_width(cr, 1) + cairo_move_to(cr, inner.x, ry) + cairo_line_to(cr, inner.x + inner.w, ry) + cairo_stroke(cr) + + -- Stats: label left, value right. + local kmh = weather.kmh(w.wind_speed) + local wind_txt = kmh + and string.format('%s %d km/h', weather.arrow(w.wind_deg), math.floor(kmh + 0.5)) + or '--' + local rows = { + { 'FEELS LIKE', w.feels_like and string.format('%d\u{00B0}', math.floor(w.feels_like + 0.5)) or '--' }, + { 'HUMIDITY', w.humidity and (w.humidity .. '%') or '--' }, + { 'WIND', wind_txt }, + } + local sy = ry + 22 + for _, row in ipairs(rows) do + card.font(cr, card.FONT_MONO, 11, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, sy, row[1]) + card.rgba(cr, colors.value) + card.text_right(cr, inner.x + inner.w, sy, row[2]) + sy = sy + 20 + end + + -- Rule. + local ry2 = sy + 2 + card.rgba(cr, colors.rule, 0.8) + cairo_move_to(cr, inner.x, ry2) + cairo_line_to(cr, inner.x + inner.w, ry2) + cairo_stroke(cr) + + -- The arc fills whatever height is left. + draw_arc(cr, inner.x, ry2 + 10, inner.w, (inner.y + inner.h) - (ry2 + 10), + colors, w, now) +end + +return M +``` + +- [ ] **Step 2: Add the widget to the layout** + +In `dashboard.lua`, replace the `layout` table: + +```lua +local layout = { + { widget = 'clock', col = 1, row = 1, w = 1, h = 2 }, + { widget = 'weather', col = 2, row = 1, w = 1, h = 2 }, +} +``` + +- [ ] **Step 3: Schedule the fetch** + +In `conky.conf.in`, replace the final two lines: + +```lua +-- Cairo output covers conky.text, so nothing here is visible. The execi still +-- fires on schedule: verified with a probe config whose only text was an execi +-- producing no output. This is what refreshes the weather cache, and tying it +-- to conky means nothing fetches while the dashboard is down. +conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}]] +``` + +- [ ] **Step 4: Check the Lua parses** + +Run: `luac -p widgets/weather.lua dashboard.lua lib/weather.lua && echo "syntax ok"` +Expected: `syntax ok` + +A syntax error here would show as a blank dashboard with no message, so this +check is worth its two seconds. + +- [ ] **Step 5: Run the test suite** + +Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` +Expected: three "all assertions passed" lines. `test_layout` asserts on the +grid, and the layout table just changed, so this is not a formality. + +- [ ] **Step 6: Commit** + +```bash +git add widgets/weather.lua dashboard.lua conky.conf.in +git commit -m "feat: add the weather card" +``` + +--- + +## Task 8: Install, look at it, and iterate + +The card has never been drawn at this point. Everything below is about seeing it. + +- [ ] **Step 1: Link `bin/` and silence the restart in UDT's install.sh** + +In `../unified-desktop-theme/install.sh`, after the existing +`widgets` symlink (around line 189): + +```bash +ln -sfn "$conky_repo/bin" "$HOME/.config/conky/bin" +``` + +And the conky restart (around line 216) gains the workspace rule it currently +lacks, so a reinstall does not pop the dashboard open: + +```bash + (hyprctl dispatch 'hl.dsp.exec_cmd("[workspace special:dash silent] conky -c ~/.config/conky/conky.conf -d")' >/dev/null 2>&1 &) +``` + +- [ ] **Step 2: Render and restart** + +```bash +cd ../unified-desktop-theme && ./install.sh +``` + +Expected: it reports conky among the reloaded components, and +`~/.config/conky/bin` now resolves. + +- [ ] **Step 3: Confirm the dashboard did not steal the workspace** + +```bash +hyprctl monitors -j | python3 -c 'import json,sys;print([(m["name"],m["specialWorkspace"]["name"]) for m in json.load(sys.stdin)])' +``` + +Expected: empty special workspace names. If `special:dash` is showing, Step 1's +restart change did not take. + +- [ ] **Step 4: Retry the API key** + +```bash +./bin/weather-fetch.sh; echo "exit: $?" +``` + +If it still reports `Invalid API key`, the key is not active yet. Continue to +Step 5 anyway: the no-data state is exactly what should be verified first, and +it is what the user sees today. + +- [ ] **Step 5: Screenshot the dashboard** + +`grim` captures screen coordinates, so it cannot shoot a window on an inactive +workspace. Switch first, and poll until the switch settles rather than sleeping +a fixed time: + +```bash +hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null +for i in $(seq 20); do + s=$(hyprctl monitors -j | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["specialWorkspace"]["name"])') + [ "$s" = "special:dash" ] && break + sleep 0.2 +done +grim -o DP-1 /tmp/dash.png +hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null +``` + +- [ ] **Step 6: Look at the PNG** + +Open `/tmp/dash.png` and read it. Checking that `grim` exited 0 proves nothing +about what was drawn; a blank card and a correct card both exit 0. + +With no live key, expect: the card chrome in place beside the clock, with +"no weather data" and the path to set. With a live key, expect: the glyph, +temperature and city on top, the condition line, three stat rows, and the arc +with a dot between the two times. + +Compare against `idea1.png`. Likely first-pass problems, each fixed in +`widgets/weather.lua` and re-screenshotted: +- Text overflowing the card's right edge: reduce the font size or shorten the label. +- The arc cramped or overlapping the stats: adjust the `ry2 + 10` offset. +- The sun dot off the curve: the Bezier evaluation and the drawn curve have + diverged, which means the control points differ between them. + +- [ ] **Step 7: Commit any adjustments** + +```bash +# from the repo root +git add widgets/weather.lua +git commit -m "fix: adjust the weather card against the screenshot" +``` + +And in the UDT repo, which is a separate checkout: + +```bash +cd ../unified-desktop-theme +git add install.sh +git commit -m "feat: link the conky bin dir, silence the conky restart" +``` + +--- + +## Task 9: Documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document the widget and its setup** + +In `README.md`, add to the widgets section a description of the weather card, +and a setup step stating: copy `weather.env.example` to +`~/.config/udt/weather.env`, add an OWM key, `chmod 600` it, and expect a new +key to return 401 for up to a few hours while it activates. + +Add to the "Gotchas worth knowing" section: + +- `${execi}` fires even though `conky.text` renders nothing, which is what + schedules the weather fetch. +- The cache is written to a temp file and renamed, because the widget reads it + on an unrelated cadence. +- A bad OWM key returns HTTP 200 with an error body, so the status code alone + proves nothing. + +- [ ] **Step 2: Verify the documented commands** + +Run every command the README now claims works, and confirm the output matches +what is written. A README that documents a command nobody ran is how the last +one drifted. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: document the weather widget and its setup" +``` + +--- + +## Self-review notes + +Checked against the spec: every section has a task. The spec's "out of scope" +(forecast) stays out. The `pressure` and `temp_min`/`temp_max` fields the user +declined are not parsed, though they remain in the cached response, which is +the point of caching it whole. + +Two things this plan cannot settle, both flagged in place: +- The API key may still be inactive. Tasks 6 and 8 both state which outcomes + are acceptable, so neither blocks. +- The exact band offsets in `widgets/weather.lua` are a first pass. Task 8 + Step 6 is where they get corrected against a screenshot, which is the only + way to judge them. |
