# 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. -- Sunrise 1500, sunset 1900, so DAY must sit INSIDE that window and NIGHT -- outside it. Naming them without checking them against the window is how the -- first draft of this test made every timestamp evaluate as night. local DAY, NIGHT = 1700, 2000 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 -- `now` is guarded alongside sunrise and sunset, not just them: comparing a -- nil now against a number raises, and a raise here is a blank dashboard. -- Missing any of the three means the sun is unknown, so the day face is -- drawn, which is what is_day() falls back to as well. local day = true if now and 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. -- -- The scale starts at 0 and ipairs starts at 1, so force N's upper bound is -- BEAUFORT[N + 1] and the loop index is already N + 1. Hence `force - 1` -- below. The scale tops out at 12, which is why the fall-through returns it. 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. -- -- Nothing calls compass() yet: the card draws arrow() instead. It is kept -- because it shares arrow()'s binning exactly, so testing both pins that -- shared logic from two angles, and swapping the card to read "S 9 km/h" -- instead of an arrow is then a one-word change rather than new code. 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 -- ponytail: a NaN timestamp slips through, since NaN is a number and every -- comparison against it is false, so both clamps below fall through and this -- returns NaN. Unreachable today: parse() matches %d+ for the sun times and -- `now` is os.time(). Cairo ignores a NaN coordinate, so the cost would be a -- missing dot, not a crash. Guard it here if a caller ever computes `now`. 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') -- Negative temperatures are ordinary here for half the year, and a number in -- exponent form must not be truncated at the 'e': reading 1.8e1 as 1.8 would -- draw 2 degrees instead of 18, wrong in a way that still looks like weather. local cold = weather.parse( '{"weather":[{"id":600,"description":"snow"}],"main":{"temp":-12.5},' .. '"sys":{"sunrise":100,"sunset":200},"name":"X","dt":150}') assert(cold and cold.temp == -12.5, 'a negative temp, got ' .. tostring(cold and cold.temp)) local exp = weather.parse( '{"weather":[{"id":800}],"main":{"temp":1.8e1},' .. '"sys":{"sunrise":100,"sunset":200}}') assert(exp and exp.temp == 18, 'exponent form, got ' .. tostring(exp and exp.temp)) ``` - [ ] **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*"([^"]*)"'), -- The exponent is accepted although OWM has never been seen to emit one: -- a pattern stopping at the 'e' would read 1.8e1 as 1.8 and draw 2 degrees -- instead of 18, silently and plausibly. Cheaper to accept than to detect. temp = tonumber(main:match('"temp"%s*:%s*(%-?[%d%.eE%+%-]+)')), feels_like = tonumber(main:match('"feels_like"%s*:%s*(%-?[%d%.eE%+%-]+)')), humidity = tonumber(main:match('"humidity"%s*:%s*(%d+)')), wind_speed = tonumber(wind:match('"speed"%s*:%s*([%d%.eE%+%-]+)')), 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') -- Neither takes `now` on faith: a nil there used to raise, and a raise is a -- blank dashboard rather than a message. assert(weather.is_stale(1000, nil) == true, 'a nil now must not raise') assert(weather.age_str(1000, nil) == '?', 'a nil now must not raise') -- A dt in the future means a clock skew. "stale -17m" reads as a broken -- widget, so the age floors at zero and the reading counts as fresh. assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m, got ' .. tostring(weather.age_str(2000, 1000))) assert(weather.is_stale(2000, 1000) == false, 'a future dt is not stale') ``` - [ ] **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 -- `now` is guarded alongside `dt` although every caller passes os.time(), -- which cannot be nil: these were the only two functions in the module that -- did arithmetic on an unchecked argument, and the module's promise is that -- nothing raises. A raise here is a blank dashboard. function M.is_stale(dt, now) if type(dt) ~= 'number' or type(now) ~= 'number' then return true end return (now - dt) > M.STALE_AFTER end -- Short age for the marker beside the city: "12m", "3h", "2d". -- -- A negative age is clamped to zero rather than printed. An observation -- timestamped in the future means a clock skew somewhere, and "stale -17m" on -- the card reads as a broken widget; treating it as fresh is both tidier and -- truer, since weather from the future is not old. function M.age_str(dt, now) if type(dt) ~= 'number' or type(now) ~= 'number' then return '?' end local s = now - dt if s < 0 then s = 0 end 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 set -a # shellcheck source=/dev/null . "$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 # The response carries the configured city and its coordinates. That is not a # secret, but it is location data and it shares a directory with UDT's other # generated state, all of which is 600. Set the mode before the body lands in # the file rather than after, so it is never briefly world-readable. umask 077 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 # Belt and braces behind `curl -f`. Measured against the live API: a bad key # returns 401 and an unknown city 404, so curl -f already rejects both and this # branch is not what catches them. It stays for the case -f cannot see: a 200 # whose body is not usable weather, which is what the polybar script this was # ported from actually hit, since it ran curl WITHOUT -f and so received error # bodies with a success exit. Without this, such a body would reach the parser # and replace a good cache. 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 }, -- Under the clock, same column. 1x1 rather than a full-height cell: the -- card's content is about 350px tall and a taller cell left a dead band -- down its middle. { widget = 'weather', col = 1, row = 2, w = 1, h = 1 }, } ``` - [ ] **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 offsets in `draw()`. - Proportions wrong in the other direction, which is what actually happened: the first draft let the arc absorb all leftover height and drew a 794px arch. The widget now scales its type to the rect and bounds the arc band, and the layout gives it a 1x1 cell. - 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.