# Mirage Weather Cards 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:** Replace the OpenWeatherMap `weather` card with five Open-Meteo cards (`now`, `hourly`, `week`, `details`, `sky`) adapted from Conky-Mirage and drawn in this repo's design language. **Architecture:** `bin/weather-fetch.sh` pulls Open-Meteo's forecast and air-quality APIs and flattens them with jq into a tab-separated cache, `~/.cache/udt/weather.txt`. `lib/weather.lua` parses that cache into a table and holds pure helpers (WMO glyphs, moon phase, thresholds). Each card reads the cache through it, draws its header and rows with a new shared `card.standard()`, and puts its Mirage-derived graphic in the space left over. **Tech Stack:** Lua 5.4 with conky's cairo bindings, bash, curl, jq. Spec: `docs/superpowers/specs/2026-10-18-mirage-weather-cards-design.md`. **Conventions for every task:** - Run from the repo root. - Full test command: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` - Commits are GPG-signed (global config does it; never disable it). End every commit message with the line `Co-Authored-By: Claude Opus 5.5 `. - No em dashes in code, comments, docs or commit messages. - Nothing raises: a Lua error in conky is a blank screen. Guard types, return nil or `'--'`. - No real location anywhere committed. Fixtures use epochs and numbers only. --- ## File map | File | Action | Responsibility | |---|---|---| | `test/fixtures/weather.txt` | create | Small location-free cache sample for the parser tests | | `test/fixtures/weather.json`, `test/fixtures/weather_truncated.json` | delete | OWM fixtures, no longer used | | `test/test_weather.lua` | rewrite | Checks for the new `lib/weather.lua` | | `lib/weather.lua` | rewrite | Open-Meteo parsing and domain helpers | | `bin/weather-fetch.sh` | rewrite | Geocode, fetch, flatten, atomic write | | `weather.env.example` | rewrite | `CITY`/`COUNTRY`/`UNITS`, no key | | `lib/card.lua` | modify | Title ICONs for the new cards, `text_centre`, `standard` | | `widgets/now.lua` | create | Current conditions | | `widgets/hourly.lua` | create | 24-hour temperature curve | | `widgets/week.lua` | create | 7-day range bars | | `widgets/details.lua` | create | Wind, humidity, UV, AQI, compass | | `widgets/sky.lua` | create | Sun times, moon, daylight arc | | `widgets/weather.lua` | delete | Replaced by `now` and `sky` | | `dashboard.lua` | modify | Layout rows for the five cards | | `README.md`, `DESIGN.md` | modify | Docs and Mirage credit | --- ### Task 1: Parser and helpers in `lib/weather.lua` **Files:** - Create: `test/fixtures/weather.txt` - Delete: `test/fixtures/weather.json`, `test/fixtures/weather_truncated.json` - Rewrite: `test/test_weather.lua` - Rewrite: `lib/weather.lua` - [ ] **Step 1: Create the fixture** Real tabs matter, so write it with printf rather than an editor: ```bash printf '%s\n' \ $'units\tmetric' \ $'current\t1792325700\t13.2\t11.5\t3\t1\t77\t10.2\t72' \ $'sun\t1792301818\t1792339510' \ $'air\t37' \ $'hour\t1792324800\t13.1\t3\t0\t1\t1.15' \ $'hour\t1792328400\t13.4\t61\t40\t1\tnull' \ $'hour\t1792332000\tnull\t3\t0\t0\t0.95' \ $'day\t1792274400\t3\t8.1\t14.6\t10' \ $'day\t1792360800\t61\t7.4\t12.0\t80' \ > test/fixtures/weather.txt git rm -q test/fixtures/weather.json test/fixtures/weather_truncated.json ``` The `null` fields are deliberate: Open-Meteo emits `null` for a missing value and jq prints it as the word. - [ ] **Step 2: Write the failing test** Replace all of `test/test_weather.lua` with: ```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 cards themselves are verified -- by rendering them. package.path = './?.lua;' .. package.path local weather = require 'lib.weather' local function slurp(path) local f = assert(io.open(path, 'r')) local s = f:read('*a') f:close() return s end -- === WMO code to glyph ==================================================== -- Open-Meteo reports WMO weather codes. The groups are sparse (0-3, 45, 48, -- 51-57, ...), so each group's first and last code is asserted, plus a code -- in a gap, which must read as unknown rather than silently sunny. local I = weather.ICON assert(weather.icon(0, true) == I.clear_day, 'code 0 day is the sun') assert(weather.icon(0, false) == I.clear_night, 'code 0 night is the moon') assert(weather.icon(1, true) == I.few_day, 'code 1 is few clouds') assert(weather.icon(2, false) == I.few_night, 'code 2 night') assert(weather.icon(3, false) == I.overcast, 'code 3 is overcast, no night face') assert(weather.icon(45, true) == I.fog, 'code 45 is fog') assert(weather.icon(48, true) == I.fog, 'code 48 is fog') assert(weather.icon(51, true) == I.drizzle_day, 'code 51 is drizzle') assert(weather.icon(57, false) == I.drizzle_night, 'code 57 night') assert(weather.icon(61, true) == I.rain_day, 'code 61 is rain') assert(weather.icon(67, true) == I.rain_day, 'code 67 is still rain') assert(weather.icon(80, false) == I.rain_night, 'code 80 showers are rain') assert(weather.icon(82, true) == I.rain_day, 'code 82 is rain') assert(weather.icon(71, true) == I.snow, 'code 71 is snow') assert(weather.icon(77, false) == I.snow, 'code 77 is snow at night too') assert(weather.icon(85, true) == I.snow, 'code 85 snow showers') assert(weather.icon(86, true) == I.snow, 'code 86 snow showers') assert(weather.icon(95, true) == I.thunder_day, 'code 95 is thunder') assert(weather.icon(99, false) == I.thunder_night, 'code 99 night') -- Gaps and junk. assert(weather.icon(4, true) == I.unknown, 'code 4 is not a WMO code') assert(weather.icon(50, true) == I.unknown, 'code 50 sits in a gap') assert(weather.icon(-1, true) == I.unknown, 'negative code') assert(weather.icon(nil, true) == I.unknown, 'nil code') assert(weather.icon('3', true) == I.unknown, 'string code') -- An unknown day/night falls back to the day face, matching is_day(). assert(weather.icon(0, nil) == I.clear_day, 'nil is_day draws the day face') -- The codepoint VALUES are deliberately not asserted. Whether a codepoint -- draws a snow cloud in the target font is not something an assertion can -- see; the glyph sheet in Task 3 is that check. -- === Descriptions ========================================================= assert(weather.describe(0) == 'Clear sky', 'code 0 words') assert(weather.describe(63) == 'Rain', 'code 63 words') assert(weather.describe(99) == 'Thunderstorm, hail', 'code 99 words') assert(weather.describe(4) == 'Unknown', 'gap code words') assert(weather.describe(nil) == 'Unknown', 'nil code words') -- === Formatting =========================================================== assert(weather.deg(13.2) == '13\u{00B0}', 'rounds down') assert(weather.deg(13.5) == '14\u{00B0}', 'rounds half up') assert(weather.deg(-0.4) == '0\u{00B0}', 'just below zero reads 0') assert(weather.deg(-2.6) == '-3\u{00B0}', 'negative rounds away from zero') assert(weather.deg(nil) == '--', 'missing temperature is --, never 0') assert(weather.num(77) == '77', 'plain number') assert(weather.num(39.6, '%') == '40%', 'rounded with suffix') assert(weather.num(0, '%') == '0%', 'zero is a real reading') assert(weather.num(nil, '%') == '--', 'missing number is --') assert(weather.wind_unit('metric') == 'km/h', 'metric wind') assert(weather.wind_unit('imperial') == 'mph', 'imperial wind') assert(weather.wind_unit(nil) == 'km/h', 'unknown units default to metric') assert(weather.duration(37692) == '10h 28m', 'day length') assert(weather.duration(0) == '0h 00m', 'zero length') assert(weather.duration(-5) == '--', 'negative length is bad data') assert(weather.duration(nil) == '--', 'missing length') -- === Thresholds =========================================================== -- Each boundary and the value just under it. assert(weather.uv_role(2.9) == 'ok', 'UV under 3 is ok') assert(weather.uv_role(3) == 'warning', 'UV 3 is warning') assert(weather.uv_role(7.9) == 'warning', 'UV under 8 is warning') assert(weather.uv_role(8) == 'critical', 'UV 8 is critical') assert(weather.uv_role(nil) == nil, 'missing UV has no role') assert(weather.aqi_role(40) == 'ok', 'AQI 40 is ok') assert(weather.aqi_role(41) == 'warning', 'AQI 41 is warning') assert(weather.aqi_role(80) == 'warning', 'AQI 80 is warning') assert(weather.aqi_role(81) == 'critical', 'AQI 81 is critical') assert(weather.aqi_role(nil) == nil, 'missing AQI has no role') -- === Moon ================================================================= -- Known 2024 phases (UTC): new 01-11 11:57, full 01-25 17:54, new 02-09 22:59. -- The mean synodic month drifts from the true one by up to about half a day, -- so the tolerance is one day of phase. local DAY_PHASE = 1 / 29.530588853 local function circ(a, b) local d = math.abs(a - b) % 1; return math.min(d, 1 - d) end local p_new = weather.moon(1704974220) local p_full, lit_full = weather.moon(1706205240) local p_new2, lit_new2 = weather.moon(1707519540) assert(circ(p_new, 0) < DAY_PHASE, 'new moon phase near 0, got ' .. p_new) assert(circ(p_full, 0.5) < DAY_PHASE, 'full moon phase near 0.5, got ' .. p_full) assert(circ(p_new2, 0) < DAY_PHASE, 'next new moon near 0, got ' .. p_new2) assert(lit_full > 0.98, 'full moon is lit, got ' .. lit_full) assert(lit_new2 < 0.02, 'new moon is dark, got ' .. lit_new2) assert(weather.moon(nil) == nil, 'missing epoch') -- 28 glyphs from U+E38D. Phase 0 and phase 1 are both the new moon. assert(weather.moon_icon(0) == utf8.char(0xE38D), 'phase 0 is new moon') assert(weather.moon_icon(0.5) == utf8.char(0xE38D + 14), 'phase 0.5 is full moon') assert(weather.moon_icon(0.999) == utf8.char(0xE38D), 'phase near 1 wraps to new') assert(weather.moon_icon(nil) == I.unknown, 'missing phase') -- === Wind ================================================================= -- Compass: eight points, each spanning 45 degrees, offset by half a step so -- north straddles 0 rather than starting at it. assert(weather.compass(0) == 'N', '0 is north') assert(weather.compass(360) == 'N', '360 wraps to north') assert(weather.compass(22) == 'N', 'just under the NE boundary') assert(weather.compass(23) == 'NE', 'just over it') assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north') assert(weather.compass(90) == 'E', '90 is east') assert(weather.compass(180) == 'S', '180 is south') 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') -- === Sun position ========================================================= -- t is the fraction of daylight elapsed. Sunrise 1000, sunset 2000. 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') 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') 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.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(500, 1000, 2000) == false, 'before dawn is night') assert(weather.is_day(nil, 1000, 2000) == true, 'unknown falls back to day') -- === Parsing the cache ==================================================== local w = weather.parse(slurp('test/fixtures/weather.txt')) assert(w, 'the fixture parses') assert(w.units == 'metric', 'units') assert(w.current.dt == 1792325700, 'current time') assert(w.current.temp == 13.2, 'current temperature') assert(w.current.feels == 11.5, 'feels like') assert(w.current.code == 3, 'current code') assert(w.current.is_day == true, 'is_day 1 is day') assert(w.current.humidity == 77, 'humidity') assert(w.current.wind == 10.2, 'wind') assert(w.current.wind_deg == 72, 'wind direction') assert(w.sun.rise == 1792301818 and w.sun.set == 1792339510, 'sun times') assert(w.air.aqi == 37, 'air quality') assert(#w.hours == 3, 'three hours, got ' .. #w.hours) assert(w.hours[1].uv == 1.15, 'first hour UV') assert(w.hours[2].uv == nil, 'null UV reads as nil') assert(w.hours[2].precip == 40, 'rain chance') assert(w.hours[3].temp == nil, 'null temperature reads as nil') assert(w.hours[3].is_day == false, 'is_day 0 is night') assert(#w.days == 2, 'two days') assert(w.days[1].min == 8.1 and w.days[1].max == 14.6, 'today min and max') assert(w.days[2].precip == 80, 'tomorrow rain chance') -- No air line: still parses, air is nil, the card shows --. local no_air = slurp('test/fixtures/weather.txt'):gsub('air\t37\n', '') local wa = weather.parse(no_air) assert(wa and wa.air == nil, 'missing air line is not fatal') -- Required records missing: nil, so the card draws its notice. assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('current\t[^\n]*\n', ''))) == nil, 'no current line is nil') assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('sun\t[^\n]*\n', ''))) == nil, 'no sun line is nil') assert(weather.parse((slurp('test/fixtures/weather.txt'):gsub('hour\t[^\n]*\n', ''))) == nil, 'no hour lines is nil') assert(weather.parse('') == nil, 'empty string') assert(weather.parse(nil) == nil, 'nil') assert(weather.parse('{"cod":401,"message":"bad"}') == nil, 'garbage') -- === Staleness ============================================================ -- Three missed fetches at the 15-minute interval. assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45m is not stale') assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'over 45m is stale') assert(weather.is_stale(nil, 1000) == true, 'no timestamp is stale') assert(weather.age_str(1000, 1000 + 12 * 60) == '12m', 'minutes') assert(weather.age_str(1000, 1000 + 3 * 3600) == '3h', 'hours') assert(weather.age_str(1000, 1000 + 2 * 86400) == '2d', 'days') assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m') assert(weather.age_str(nil, 1000) == '?', 'missing dt') print('test_weather: all assertions passed') ``` - [ ] **Step 3: Run test to verify it fails** Run: `lua test/test_weather.lua` Expected: FAIL, an assertion error on the first new-API call (e.g. `code 1 is few clouds`, or `attempt to call a nil value (field 'describe')`). - [ ] **Step 4: Write the implementation** Replace all of `lib/weather.lua` with: ```lua -- Open-Meteo domain knowledge and cache parsing. -- -- Separate from lib/data.lua: that reads /proc and /sys, this reads the cache -- bin/weather-fetch.sh flattens Open-Meteo's responses into. Same testable -- shape though, every function takes a string or numbers and returns a value, -- so the tests need no filesystem and no network. -- -- The forecast cards built on this (hourly, week, details, sky) are adapted -- from the Conky-Mirage theme by alexsson-xexpanderx: -- https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage -- -- 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 = {} M.CACHE = (os.getenv('XDG_CACHE_HOME') or ((os.getenv('HOME') or '') .. '/.cache')) .. '/udt/weather.txt' -- 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}', rain_day = '\u{E308}', rain_night = '\u{E325}', snow = '\u{E31A}', fog = '\u{E313}', clear_day = '\u{E30D}', clear_night = '\u{E32B}', few_day = '\u{E302}', few_night = '\u{E379}', overcast = '\u{E312}', unknown = '\u{E374}', } -- WMO weather code to glyph. The codes are sparse groups, not ranges, so a -- code in a gap is unknown rather than borrowed from a neighbour. -- `is_day` false picks the night face; nil (unknown) draws the day face, which -- is what is_day() falls back to as well. function M.icon(code, is_day) if type(code) ~= 'number' then return M.ICON.unknown end local d = is_day ~= false local I = M.ICON if code == 0 then return d and I.clear_day or I.clear_night elseif code == 1 or code == 2 then return d and I.few_day or I.few_night elseif code == 3 then return I.overcast elseif code == 45 or code == 48 then return I.fog elseif code >= 51 and code <= 57 then return d and I.drizzle_day or I.drizzle_night elseif (code >= 61 and code <= 67) or (code >= 80 and code <= 82) then return d and I.rain_day or I.rain_night elseif (code >= 71 and code <= 77) or code == 85 or code == 86 then return I.snow elseif code >= 95 and code <= 99 then return d and I.thunder_day or I.thunder_night end return I.unknown end local DESCRIBE = { [0] = 'Clear sky', [1] = 'Mainly clear', [2] = 'Partly cloudy', [3] = 'Overcast', [45] = 'Fog', [48] = 'Rime fog', [51] = 'Light drizzle', [53] = 'Drizzle', [55] = 'Dense drizzle', [56] = 'Freezing drizzle', [57] = 'Freezing drizzle', [61] = 'Light rain', [63] = 'Rain', [65] = 'Heavy rain', [66] = 'Freezing rain', [67] = 'Freezing rain', [71] = 'Light snow', [73] = 'Snow', [75] = 'Heavy snow', [77] = 'Snow grains', [80] = 'Rain showers', [81] = 'Rain showers', [82] = 'Violent showers', [85] = 'Snow showers', [86] = 'Snow showers', [95] = 'Thunderstorm', [96] = 'Thunderstorm, hail', [99] = 'Thunderstorm, hail', } -- WMO code in words. Reading a nil key from a table is not an error. function M.describe(code) return DESCRIBE[code] or 'Unknown' end local function round(v) return math.floor(v + 0.5) end -- A temperature as the cards print it. Missing is '--', never '0': zero is a -- legitimate reading. The '-0' a value just under zero would round to is -- folded into '0'. function M.deg(v) if type(v) ~= 'number' then return '--' end local r = round(v) if r == 0 then r = 0 end return string.format('%d\u{00B0}', r) end -- A rounded number with an optional suffix, or '--'. function M.num(v, suffix) if type(v) ~= 'number' then return '--' end return string.format('%d', round(v)) .. (suffix or '') end -- The fetcher asks Open-Meteo for km/h or mph to match UNITS. function M.wind_unit(units) return units == 'imperial' and 'mph' or 'km/h' end -- Seconds as '10h 28m'. function M.duration(s) if type(s) ~= 'number' or s < 0 then return '--' end return string.format('%dh %02dm', math.floor(s / 3600), math.floor(s % 3600 / 60)) end -- Threshold role names, which index the palette (colors.ok and so on). -- UV: WHO bands, low under 3, very high from 8. function M.uv_role(uv) if type(uv) ~= 'number' then return nil end if uv >= 8 then return 'critical' elseif uv >= 3 then return 'warning' end return 'ok' end -- European AQI: good and fair up to 40, moderate and poor up to 80. function M.aqi_role(aqi) if type(aqi) ~= 'number' then return nil end if aqi > 80 then return 'critical' elseif aqi > 40 then return 'warning' end return 'ok' end -- Moon phase from the mean synodic month against a known new moon -- (2000-01-06 18:14 UTC). Returns phase 0..1 (0 new, 0.5 full) and the lit -- fraction 0..1. The mean month is off the true one by under a day, which a -- 28-step glyph cannot show anyway. local SYNODIC = 29.530588853 local NEW_MOON = 947182440 function M.moon(epoch) if type(epoch) ~= 'number' then return nil end local phase = ((epoch - NEW_MOON) / 86400 / SYNODIC) % 1 return phase, (1 - math.cos(2 * math.pi * phase)) / 2 end -- One of the 28 Nerd Font moon glyphs, U+E38D (new) to U+E3A8, in phase order. function M.moon_icon(phase) if type(phase) ~= 'number' then return M.ICON.unknown end return utf8.char(0xE38D + round(phase * 28) % 28) 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. 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 -- Fraction of daylight elapsed, clamped to 0..1. -- -- The cache carries 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. 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; unknown falls back to day. 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 -- Parse the cache: tab-separated records, first field the record type. -- -- units metric -- current epoch temp feels code is_day humidity wind wind_deg -- sun sunrise sunset -- air european_aqi -- hour epoch temp code precip_prob is_day uv (24 of them) -- day epoch code min max precip_prob (7 of them) -- -- A field Open-Meteo left null arrives as the word 'null', which tonumber -- turns into nil. Returns nil when current, sun or every hour is missing: -- a partial card is worse than an honest "no data". function M.parse(src) if type(src) ~= 'string' or src == '' then return nil end local w = { hours = {}, days = {} } for line in src:gmatch('[^\n]+') do local f = {} for field in (line .. '\t'):gmatch('([^\t]*)\t') do f[#f + 1] = field end local function n(i) return tonumber(f[i]) end local kind = f[1] if kind == 'units' then w.units = f[2] elseif kind == 'current' then w.current = { dt = n(2), temp = n(3), feels = n(4), code = n(5), is_day = n(6) ~= 0, humidity = n(7), wind = n(8), wind_deg = n(9) } elseif kind == 'sun' then w.sun = { rise = n(2), set = n(3) } elseif kind == 'air' then w.air = { aqi = n(2) } elseif kind == 'hour' then w.hours[#w.hours + 1] = { t = n(2), temp = n(3), code = n(4), precip = n(5), is_day = n(6) ~= 0, uv = n(7) } elseif kind == 'day' then w.days[#w.days + 1] = { t = n(2), code = n(3), min = n(4), max = n(5), precip = n(6) } end end if not (w.current and w.current.temp and w.sun and w.sun.rise and w.sun.set and #w.hours > 0) then return nil end return w end -- Three missed fetches at the 15-minute interval. M.STALE_AFTER = 45 * 60 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: "12m", "3h", "2d". A negative age (clock skew) reads as fresh. 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 return M ``` Note on `is_day = n(6) ~= 0`: a missing field gives `nil ~= 0`, which is `true`, so unknown draws the day face, matching the rest of the module. - [ ] **Step 5: Run test to verify it passes** Run: `lua test/test_weather.lua` Expected: `test_weather: all assertions passed` If `weather.deg(-0.4)` fails with `-0°`: `round(-0.4)` is `math.floor(0.1)` = `0` (integer), so it should pass; if Lua returns float `-0.0`, the `r == 0` fold handles it. - [ ] **Step 6: Commit** `widgets/weather.lua` still requires the old API and will draw wrongly until Task 4 deletes it. That is acceptable for one commit in the middle of this plan; do not restart conky until Task 4. ```bash git add lib/weather.lua test/test_weather.lua test/fixtures/weather.txt git commit -m "feat(weather): parse an Open-Meteo cache instead of OWM JSON WMO codes to glyphs and words, moon phase, UV and AQI roles, and a parser for the tab-separated cache the fetcher will write. Groundwork for the Mirage-derived forecast cards. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Open-Meteo fetcher **Files:** - Rewrite: `bin/weather-fetch.sh` - Rewrite: `weather.env.example` - [ ] **Step 1: Rewrite the fetcher** Replace all of `bin/weather-fetch.sh` with (keep it executable): ```bash #!/bin/bash # Fetch the forecast from Open-Meteo into a cache file. Run from conky's # ${texeci}, so it prints nothing on success and never blocks the dashboard: # the widgets only ever read the cache. # # Open-Meteo needs no key. The location is a city name from weather.env, # geocoded once and cached, so no coordinates live in this repository. # # 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.txt" LOC="$CACHE_DIR/weather-loc" 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 "${CITY:-}" ]; then echo "weather-fetch: CITY is empty in $ENV_FILE" >&2 exit 1 fi UNITS="${UNITS:-metric}" mkdir -p "$CACHE_DIR" # Location data shares a directory with UDT's other generated state, all of # which is 600. Set the mode before anything lands, so no file is briefly # world-readable. umask 077 # Temp files sit beside the target, because rename is only atomic within a # filesystem. The widgets read the cache on their own 2s cadence, so a fetch # killed mid-write would otherwise hand them half a cache. TMP="$CACHE.tmp.$$" FC="$TMP.forecast" AIR="$TMP.air" trap 'rm -f "$TMP" "$FC" "$AIR"' EXIT # Geocode once per CITY/COUNTRY pair. The cache's first field records which # pair it answers, so editing weather.env re-geocodes on the next run. PAIR="$CITY,${COUNTRY:-}" lat="" lon="" cached="" if [ -r "$LOC" ]; then IFS=$'\t' read -r cached lat lon < "$LOC" [ "$cached" = "$PAIR" ] || lat="" fi if [ -z "$lat" ]; then read -r lat lon < <(curl -fsS --max-time 15 --get \ "https://geocoding-api.open-meteo.com/v1/search" \ --data-urlencode "name=$CITY" --data-urlencode "count=10" 2>/dev/null \ | jq -r --arg cc "${COUNTRY:-}" ' [.results[]? | select($cc == "" or .country_code == ($cc | ascii_upcase))][0] | select(. != null) | "\(.latitude) \(.longitude)"') if [ -z "$lat" ]; then echo "weather-fetch: cannot geocode $PAIR" >&2 exit 1 fi printf '%s\t%s\t%s\n' "$PAIR" "$lat" "$lon" > "$LOC" fi if [ "$UNITS" = imperial ]; then TEMP_UNIT=fahrenheit WIND_UNIT=mph else TEMP_UNIT=celsius WIND_UNIT=kmh fi if ! curl -fsS --max-time 15 --get "https://api.open-meteo.com/v1/forecast" \ --data-urlencode "latitude=$lat" --data-urlencode "longitude=$lon" \ --data-urlencode "timeformat=unixtime" --data-urlencode "timezone=auto" \ --data-urlencode "forecast_days=7" \ --data-urlencode "temperature_unit=$TEMP_UNIT" \ --data-urlencode "wind_speed_unit=$WIND_UNIT" \ --data-urlencode "current=temperature_2m,apparent_temperature,weather_code,is_day,relative_humidity_2m,wind_speed_10m,wind_direction_10m" \ --data-urlencode "hourly=temperature_2m,weather_code,precipitation_probability,is_day,uv_index" \ --data-urlencode "daily=weather_code,temperature_2m_min,temperature_2m_max,precipitation_probability_max,sunrise,sunset" \ -o "$FC" 2>/dev/null; then echo "weather-fetch: forecast request failed" >&2 exit 1 fi # Air quality is a second service. Losing it costs one row, not the cache, so # a failure leaves an empty file and the air line is simply omitted. curl -fsS --max-time 15 --get "https://air-quality-api.open-meteo.com/v1/air-quality" \ --data-urlencode "latitude=$lat" --data-urlencode "longitude=$lon" \ --data-urlencode "current=european_aqi" \ -o "$AIR" 2>/dev/null || : > "$AIR" # Flatten to the line format lib/weather.lua parses. The 24 hours start at # the current hour: the first hourly slot later than an hour before now. if ! jq -r --arg units "$UNITS" --slurpfile air "$AIR" ' .current.time as $now | ([.hourly.time | to_entries[] | select(.value > $now - 3600) | .key] | first) as $i | "units\t\($units)", (.current | "current\t\(.time)\t\(.temperature_2m)\t\(.apparent_temperature)\t\(.weather_code)\t\(.is_day)\t\(.relative_humidity_2m)\t\(.wind_speed_10m)\t\(.wind_direction_10m)"), "sun\t\(.daily.sunrise[0])\t\(.daily.sunset[0])", ($air[0].current.european_aqi // empty | "air\t\(.)"), (.hourly as $h | range($i; [$i + 24, ($h.time | length)] | min) as $k | "hour\t\($h.time[$k])\t\($h.temperature_2m[$k])\t\($h.weather_code[$k])\t\($h.precipitation_probability[$k])\t\($h.is_day[$k])\t\($h.uv_index[$k])"), (.daily as $d | range(0; $d.time | length) as $k | "day\t\($d.time[$k])\t\($d.weather_code[$k])\t\($d.temperature_2m_min[$k])\t\($d.temperature_2m_max[$k])\t\($d.precipitation_probability_max[$k])") ' "$FC" > "$TMP" 2>/dev/null; then echo "weather-fetch: cannot read the forecast response" >&2 exit 1 fi # A 200 whose body is not a usable forecast must not replace a good cache. if ! grep -q $'^current\t[0-9]*\t-\\?[0-9]' "$TMP"; then echo "weather-fetch: forecast has no current temperature" >&2 exit 1 fi mv -f "$TMP" "$CACHE" ``` - [ ] **Step 2: Rewrite the env example** Replace all of `weather.env.example` with: ``` # Copy to ~/.config/udt/weather.env and fill in. That path is outside this # repo on purpose: a location is personal data, and anything committed is # potentially public. chmod 600 it. # # The forecast comes from Open-Meteo (https://open-meteo.com), which needs no # key. CITY is geocoded once through Open-Meteo's geocoding API; COUNTRY is the # two-letter ISO code that picks the right place among same-named ones. # UNITS is metric (C, km/h) or imperial (F, mph). CITY=Example City COUNTRY=XX UNITS=metric ``` - [ ] **Step 3: Run the fetcher against the live API** The user's `~/.config/udt/weather.env` already has `CITY`/`COUNTRY`/`UNITS` from the OWM setup; its `KEY` line is now ignored. Run: `bash -n bin/weather-fetch.sh && ./bin/weather-fetch.sh; echo "exit: $?"; wc -l ~/.cache/udt/weather.txt; cut -f1 ~/.cache/udt/weather.txt | sort | uniq -c` Expected: `exit: 0`, `35` lines, and counts `7 day`, `24 hour`, `1 air`, `1 current`, `1 sun`, `1 units`. Do not paste the file contents or `weather-loc` anywhere committed: they locate the user. - [ ] **Step 4: Check the failure paths keep the cache** Run: `cp ~/.cache/udt/weather.txt /tmp/claude-wx.bak; WEATHER_ENV=/nonexistent ./bin/weather-fetch.sh; echo "exit: $?"; cmp ~/.cache/udt/weather.txt /tmp/claude-wx.bak && echo unchanged; rm /tmp/claude-wx.bak` Expected: `weather-fetch: no /nonexistent (copy weather.env.example)`, `exit: 1`, `unchanged`. - [ ] **Step 5: Check the parser reads the live cache** Run: `lua -e "package.path='./?.lua;'..package.path; local w=require('lib.weather'); local f=io.open(w.CACHE); local t=w.parse(f:read('a')); f:close(); print(t and (#t.hours..' hours '..#t.days..' days aqi '..tostring(t.air and t.air.aqi)) or 'PARSE FAILED')"` Expected: `24 hours 7 days aqi ` - [ ] **Step 6: Commit** ```bash git add bin/weather-fetch.sh weather.env.example git commit -m "feat(weather): fetch from Open-Meteo, no API key Forecast and air quality from Open-Meteo, flattened by jq into the tab-separated cache lib/weather.lua parses. The city is geocoded once and cached; a failed air-quality call drops one line, not the cache. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Card primitives and glyph check **Files:** - Modify: `lib/card.lua` (the `M.ICON` table near the end, plus two new functions after `M.text_right`) - [ ] **Step 1: Add the title glyphs** In `lib/card.lua`, replace the `M.ICON` table with: ```lua M.ICON = { breaktimer = '\u{F13AB}', -- timer calendar = '\u{F0E17}', -- calendar-month immich = '\u{F02F9}', -- image-multiple hourly = '\u{F0150}', -- clock-outline week = '\u{F0A33}', -- calendar-week details = '\u{F059D}', -- weather-windy sky = '\u{F0594}', -- weather-night } ``` - [ ] **Step 2: Add `text_centre` after `M.text_right`** ```lua -- Centred text: x is the CENTRE. function M.text_centre(cr, x, y, s) local w = M.measure(cr, s) M.text(cr, x - w / 2, y, s) end ``` - [ ] **Step 3: Add `standard` after `M.fit_unit`** ```lua -- The DESIGN.md card in one call: the title top-left, the big value top-right, -- then label-left / value-right rows, all from one fluid base size S. -- -- spec.icon, spec.title the title run, as M.title draws it -- spec.big the big value; nil drops it (a band carries the value) -- spec.big_color defaults to colors.body -- spec.big_font defaults to FONT_HEAVY; FONT_MONO for a glyph -- spec.rows { {label, value, color}, ... }, color defaults to colors.value -- spec.share fraction of inner.h the header and rows may use (default 1) -- -- Returns the y where the drawn content ends and S, so a card can put its -- variable band in what is left and size the band's type off the same S. -- The proportions are the breaktimer card's, which this generalises. function M.standard(cr, inner, colors, spec) local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.32 local rows = spec.rows or {} local big_font = spec.big_font or M.FONT_HEAVY local head = { { M.title_str(spec.icon, spec.title), M.FONT_MONO, LABEL_F } } if spec.big then head[2] = { spec.big, big_font, BIG_F } end local groups = { head } for _, r in ipairs(rows) do groups[#groups + 1] = { { r[1], M.FONT_MONO, ROW_F }, { tostring(r[2]), M.FONT_MONO, ROW_F } } end -- Height in units of S: the header line, the gap under it, a step per row. local head_f = spec.big and BIG_F or LABEL_F * 1.2 local budget = inner.h * (spec.share or 1) local S = clamp(math.min(budget * 0.94 / (head_f + 0.5 + #rows * 0.66), M.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) local label_size, row_size = S * LABEL_F, S * ROW_F if spec.big then M.font(cr, big_font, S * BIG_F, false) M.rgba(cr, spec.big_color or colors.body) M.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, spec.big) end M.font(cr, M.FONT_MONO, label_size, false) M.title(cr, inner.x, inner.y + label_size, spec.icon, spec.title, colors) local step = row_size * 2.0 local y = inner.y + S * head_f + row_size * 1.6 local last = inner.y + S * head_f M.font(cr, M.FONT_MONO, row_size, false) for _, r in ipairs(rows) do if y > inner.y + inner.h then break end M.rgba(cr, colors.label) M.text(cr, inner.x, y, r[1]) M.rgba(cr, r[3] or colors.value) M.text_right(cr, inner.x + inner.w, y, tostring(r[2])) last = y y = y + step end return last + row_size * 0.8, S end ``` - [ ] **Step 4: Render a glyph sheet and look at it** Write `/tmp/claude-glyphs.lua` (scratch, never committed): ```lua package.cpath = '/usr/lib64/conky/lib?.so;' .. package.cpath package.path = './?.lua;' .. package.path require 'cairo' local card = require 'lib.card' local weather = require 'lib.weather' local items = {} for k, v in pairs(card.ICON) do items[#items + 1] = { 'card.' .. k, v } end for k, v in pairs(weather.ICON) do items[#items + 1] = { k, v } end for i = 0, 27 do items[#items + 1] = { 'moon ' .. i, utf8.char(0xE38D + i) } end table.sort(items, function(a, b) return a[1] < b[1] end) local s = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 900, 40 * #items // 3 + 60) local cr = cairo_create(s) cairo_set_source_rgb(cr, 0.1, 0.1, 0.12); cairo_paint(cr) for i, it in ipairs(items) do local x, y = ((i - 1) % 3) * 300 + 10, ((i - 1) // 3) * 40 + 36 card.font(cr, card.FONT_MONO, 28, false); cairo_set_source_rgb(cr, 1, 1, 1) card.text(cr, x, y, it[2]) card.font(cr, card.FONT_MONO, 14, false); cairo_set_source_rgb(cr, 0.7, 0.7, 0.7) card.text(cr, x + 44, y - 6, it[1]) end cairo_surface_write_to_png(s, '/tmp/claude-glyphs.png') ``` Run: `lua /tmp/claude-glyphs.lua` then open `/tmp/claude-glyphs.png` with the Read tool. Expected: every glyph drawn and matching its name; moon 0 is a dark disc, moon 14 a full disc, phases progress in between. If any title glyph is wrong or blank, pick a neighbour from the Nerd Font cheat sheet (`nf-md-*`), update the codepoint, rerender. Do not commit until all look right. - [ ] **Step 5: Run the 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 prints its own pass line). - [ ] **Step 6: Commit** ```bash git add lib/card.lua git commit -m "feat(card): standard header-and-rows helper, centred text, forecast glyphs card.standard() draws the DESIGN.md card (title left, big value right, label/value rows) from one fluid size and returns where it stopped, so the forecast cards share it instead of copying breaktimer's layout five times. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: `now` card, replacing `weather` **Files:** - Create: `widgets/now.lua` - Delete: `widgets/weather.lua` - Modify: `dashboard.lua` (layout row `widget = 'weather'`) - [ ] **Step 1: Write the widget** Create `widgets/now.lua`: ```lua -- Now: the current conditions. Replaces the OpenWeatherMap weather card; the -- daylight arc it carried moved to the sky card. -- -- One of the forecast cards adapted from the Conky-Mirage theme by -- alexsson-xexpanderx (see lib/weather.lua). local card = require 'lib.card' local data = require 'lib.data' local weather = require 'lib.weather' local M = {} function M.draw(cr, rect, colors) local inner = card.card(cr, rect, colors) local w = weather.parse(data.slurp(weather.CACHE)) if not w then -- Closed explicitly: this branch reruns every frame until a fetch lands, -- so an unclosed handle would accumulate one per draw. local envf = io.open((os.getenv('HOME') or '') .. '/.config/udt/weather.env', 'r') local hint = 'set ~/.config/udt/weather.env' if envf then envf:close() hint = 'bin/weather-fetch.sh' end card.notice(cr, inner, colors, 'WEATHER', 'no weather data', hint) return end local c, today, now = w.current, w.days[1] or {}, os.time() local rows = { { 'FEELS LIKE', weather.deg(c.feels) }, { 'HIGH / LOW', weather.deg(today.max) .. ' / ' .. weather.deg(today.min) }, { 'RAIN', weather.num(today.precip, '%') }, { 'UPDATED', weather.age_str(c.dt, now), weather.is_stale(c.dt, now) and colors.warning or nil }, } local y, S = card.standard(cr, inner, colors, { icon = weather.icon(c.code, c.is_day), title = 'WEATHER', big = weather.deg(c.temp), rows = rows, share = 0.85, }) -- The condition in words, in the prose face, in what the rows left. local size = S * 0.36 if y + size * 1.4 <= inner.y + inner.h then card.font(cr, card.FONT_UI, size, false) card.rgba(cr, colors.body) card.text(cr, inner.x, y + size * 1.4, weather.describe(c.code)) end end return M ``` - [ ] **Step 2: Swap the layout row and delete the old card** In `dashboard.lua`, change: ```lua { widget = 'weather', col = 1, row = 7, w = 3, h = 5 }, ``` to: ```lua { widget = 'now', col = 1, row = 7, w = 3, h = 5 }, ``` Then: `git rm -q widgets/weather.lua` - [ ] **Step 3: Render offscreen and look** Run: `lua test/render.lua now 16 12 3 5 /tmp/claude-now.png` Expected: `now at 3x5 on a 16x12 grid -> /tmp/claude-now.png`. Open it with Read: condition glyph and `WEATHER` top-left, big temperature top-right, four label/value rows, description under them, nothing overlapping or clipped. Also check the no-data face: `XDG_CACHE_HOME=/nonexistent lua test/render.lua now 16 12 3 5 /tmp/claude-now-empty.png` and open it: chrome with `WEATHER`, `no weather data`, `bin/weather-fetch.sh`. - [ ] **Step 4: Suite, then commit** Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` Expected: all pass. ```bash git add widgets/now.lua dashboard.lua git commit -m "feat(now): current-conditions card replaces the OWM weather card Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: `hourly` card **Files:** - Create: `widgets/hourly.lua` - [ ] **Step 1: Write the widget** ```lua -- Hourly: the next 24 hours as a temperature curve over rain-chance bars. -- -- Adapted from the Conky-Mirage theme's hourly tile by alexsson-xexpanderx -- (see lib/weather.lua), redrawn in this repo's palette, glyphs and fonts. local card = require 'lib.card' local data = require 'lib.data' local weather = require 'lib.weather' local M = {} -- The band, top to bottom: glyph line, temperature line, curve, hour line. -- Every third hour is labelled; all 24 shape the curve. local function draw_curve(cr, x, y, w, h, colors, hours, S) local n = #hours local label = math.max(9, math.min(S * 0.30, h * 0.11)) local glyph = label * 1.5 local slot = w / n local function px(i) return x + (i - 0.5) * slot end local top = y + glyph + label * 1.6 local base = y + h - label * 1.6 if base - top < label * 2 then return end -- no room: no curve beats a broken one local lo, hi = math.huge, -math.huge for _, hr in ipairs(hours) do if hr.temp then lo, hi = math.min(lo, hr.temp), math.max(hi, hr.temp) end end if lo > hi then return end -- A flat day still needs a span to divide by; 1 degree keeps it mid-band. local span = math.max(hi - lo, 1) local function py(t) return base - (base - top) * (0.15 + 0.7 * (t - lo) / span) end -- Rain chance as bars rising from the baseline, under the curve. card.rgba(cr, colors.rule, 0.5) for i, hr in ipairs(hours) do if hr.precip and hr.precip > 0 then local bh = (base - top) * 0.5 * hr.precip / 100 cairo_rectangle(cr, px(i) - slot * 0.3, base - bh, slot * 0.6, bh) end end cairo_fill(cr) card.rgba(cr, colors.rule, 0.6) cairo_set_line_width(cr, 1) cairo_move_to(cr, x, base) cairo_line_to(cr, x + w, base) cairo_stroke(cr) -- The curve, broken where an hour has no temperature rather than bridged. card.rgba(cr, colors.highlight) cairo_set_line_width(cr, math.max(1.5, label * 0.15)) local pen = false for i, hr in ipairs(hours) do if hr.temp then if pen then cairo_line_to(cr, px(i), py(hr.temp)) else cairo_move_to(cr, px(i), py(hr.temp)) end pen = true else pen = false end end cairo_stroke(cr) for i = 1, n, 3 do local hr = hours[i] card.font(cr, card.FONT_MONO, glyph, false) card.rgba(cr, colors.highlight) card.text_centre(cr, px(i), y + glyph, weather.icon(hr.code, hr.is_day)) card.font(cr, card.FONT_MONO, label, false) card.rgba(cr, colors.value) card.text_centre(cr, px(i), y + glyph + label * 1.3, weather.deg(hr.temp)) card.rgba(cr, colors.label) card.text_centre(cr, px(i), y + h - label * 0.2, hr.t and os.date('%H', hr.t) or '--') end end function M.draw(cr, rect, colors) local inner = card.card(cr, rect, colors) local w = weather.parse(data.slurp(weather.CACHE)) if not w then card.notice(cr, inner, colors, 'NEXT 24H', 'no weather data', 'bin/weather-fetch.sh') return end -- hours[1] is the current hour, so the big value is the one after it. local y, S = card.standard(cr, inner, colors, { icon = card.ICON.hourly, title = 'NEXT 24H', big = weather.deg((w.hours[2] or {}).temp), share = 0.3, }) draw_curve(cr, inner.x, y, inner.w, inner.y + inner.h - y, colors, w.hours, S) end return M ``` - [ ] **Step 2: Render and look** Run: `lua test/render.lua hourly 16 12 7 3 /tmp/claude-hourly.png`, open it. Expected: header line, then 8 labelled columns (glyph, temperature, hour), a continuous highlight curve, faint bars where rain is forecast, nothing clipped. Also render at `5 3` and `7 2` and check the curve drops out cleanly rather than overlapping when the band is too short. - [ ] **Step 3: Commit** ```bash git add widgets/hourly.lua git commit -m "feat(hourly): next-24-hours card adapted from Conky-Mirage Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 6: `week` card **Files:** - Create: `widgets/week.lua` - [ ] **Step 1: Write the widget** ```lua -- Week: seven days of glyph and min-to-max range bar. -- -- Adapted from the Conky-Mirage theme's week tile by alexsson-xexpanderx -- (see lib/weather.lua). Each bar sits on the week's overall min..max scale, -- so a warm day reads as a bar further right, not just a longer one. local card = require 'lib.card' local data = require 'lib.data' local weather = require 'lib.weather' local M = {} local function draw_days(cr, x, y, w, h, colors, days, S) local n = #days if n == 0 then return end local step = h / n local size = math.min(S * 0.32, step * 0.55) if size < 8 then return end local lo, hi = math.huge, -math.huge for _, d in ipairs(days) do if d.min then lo = math.min(lo, d.min) end if d.max then hi = math.max(hi, d.max) end end local span = (hi > lo) and (hi - lo) or 1 -- Columns as fractions of the width: day | glyph | min | bar | max. local gx, minx = x + w * 0.20, x + w * 0.42 local bx0, bx1 = x + w * 0.46, x + w * 0.84 local function bx(t) return bx0 + (bx1 - bx0) * (t - lo) / span end cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND) for i, d in ipairs(days) do local by = y + step * (i - 0.5) + size * 0.35 -- baseline, centred in the row card.font(cr, card.FONT_MONO, size, false) card.rgba(cr, colors.label) card.text(cr, x, by, i == 1 and 'TODAY' or (d.t and os.date('%a', d.t):upper() or '--')) card.rgba(cr, colors.highlight) card.text(cr, gx, by, weather.icon(d.code, true)) card.rgba(cr, colors.value) card.text_right(cr, minx, by, weather.deg(d.min)) card.text_right(cr, x + w, by, weather.deg(d.max)) -- The track spans the week; the fill is this day. local my = by - size * 0.35 cairo_set_line_width(cr, size * 0.35) card.rgba(cr, colors.rule, 0.35) cairo_move_to(cr, bx0, my) cairo_line_to(cr, bx1, my) cairo_stroke(cr) if d.min and d.max then card.rgba(cr, colors.highlight) cairo_move_to(cr, bx(d.min), my) cairo_line_to(cr, bx(d.max), my) cairo_stroke(cr) end end cairo_set_line_cap(cr, CAIRO_LINE_CAP_BUTT) end function M.draw(cr, rect, colors) local inner = card.card(cr, rect, colors) local w = weather.parse(data.slurp(weather.CACHE)) if not w then card.notice(cr, inner, colors, 'WEEK', 'no weather data', 'bin/weather-fetch.sh') return end local y, S = card.standard(cr, inner, colors, { icon = card.ICON.week, title = 'WEEK', share = 0.25, }) draw_days(cr, inner.x, y, inner.w, inner.y + inner.h - y, colors, w.days, S) end return M ``` `cairo_set_line_cap` is reset to BUTT at the end because cairo state is shared with every card drawn after this one. - [ ] **Step 2: Render and look** Run: `lua test/render.lua week 16 12 3.5 9 /tmp/claude-week.png`, open it. Expected: title, then 7 evenly spaced rows `TODAY`, weekday names, glyphs, min right-aligned before the bar, bars offset by temperature, max at the right edge. Columns do not collide. Also render at `3 5`. - [ ] **Step 3: Commit** ```bash git add widgets/week.lua git commit -m "feat(week): seven-day range-bar card adapted from Conky-Mirage Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 7: `details` card **Files:** - Create: `widgets/details.lua` - [ ] **Step 1: Write the widget** ```lua -- Details: wind, humidity, UV and air quality, over a wind compass. -- -- Adapted from the Conky-Mirage theme's details tiles by alexsson-xexpanderx -- (see lib/weather.lua). UV and AQI are the measured quantities here, so they -- carry the ok / warning / critical roles. local card = require 'lib.card' local data = require 'lib.data' local weather = require 'lib.weather' local M = {} local function draw_compass(cr, x, y, w, h, colors, deg, S) local r = math.min(w, h) / 2 - S * 0.1 if r < 24 then return end local cx, cy = x + w / 2, y + h / 2 card.rgba(cr, colors.rule, 0.8) cairo_set_line_width(cr, 1) cairo_new_path(cr) cairo_arc(cr, cx, cy, r, 0, math.pi * 2) cairo_stroke(cr) local size = math.max(9, r * 0.2) card.font(cr, card.FONT_MONO, size, false) card.rgba(cr, colors.label) for i, p in ipairs({ 'N', 'E', 'S', 'W' }) do local a = (i - 1) * math.pi / 2 card.text_centre(cr, cx + (r - size) * math.sin(a), cy - (r - size) * math.cos(a) + size * 0.35, p) end if type(deg) ~= 'number' then return end -- Open-Meteo gives where the wind comes FROM; the needle points where it goes. local a = math.rad(deg + 180) local tip = r - size * 2 local tx, ty = cx + tip * math.sin(a), cy - tip * math.cos(a) card.rgba(cr, colors.highlight) cairo_set_line_width(cr, math.max(2, r * 0.04)) cairo_move_to(cr, cx - tip * 0.6 * math.sin(a), cy + tip * 0.6 * math.cos(a)) cairo_line_to(cr, tx, ty) cairo_stroke(cr) cairo_new_path(cr) cairo_arc(cr, tx, ty, math.max(3, r * 0.06), 0, math.pi * 2) cairo_fill(cr) end function M.draw(cr, rect, colors) local inner = card.card(cr, rect, colors) local w = weather.parse(data.slurp(weather.CACHE)) if not w then card.notice(cr, inner, colors, 'DETAILS', 'no weather data', 'bin/weather-fetch.sh') return end local c = w.current -- UV is the forecast for the current hour; Open-Meteo's current block has none. local uv = (w.hours[1] or {}).uv local aqi = w.air and w.air.aqi local wind = '--' if type(c.wind) == 'number' then wind = weather.num(c.wind) .. ' ' .. weather.wind_unit(w.units) .. ' ' .. (weather.compass(c.wind_deg) or '') end local rows = { { 'WIND', wind }, { 'HUMIDITY', weather.num(c.humidity, '%') }, { 'UV', type(uv) == 'number' and string.format('%.1f', uv) or '--', colors[weather.uv_role(uv)] }, { 'AIR (EAQI)', weather.num(aqi), colors[weather.aqi_role(aqi)] }, } local y, S = card.standard(cr, inner, colors, { icon = card.ICON.details, title = 'DETAILS', rows = rows, share = 0.6, }) draw_compass(cr, inner.x, y, inner.w, inner.y + inner.h - y, colors, c.wind_deg, S) end return M ``` `colors[nil]` is a plain table read of a nil key, which returns nil without raising, so a missing role falls back to `colors.value` inside `card.standard`. - [ ] **Step 2: Render and look** Run: `lua test/render.lua details 16 12 3.5 4.5 /tmp/claude-details.png`, open it. Expected: title, four rows with UV and AQI in a threshold colour, compass circle with N/E/S/W and a needle below. Also render at `2 3` and confirm the compass drops out rather than overlapping the rows. - [ ] **Step 3: Commit** ```bash git add widgets/details.lua git commit -m "feat(details): wind, humidity, UV and AQI card adapted from Conky-Mirage UV and the European AQI carry the ok/warning/critical roles, which puts critical back on the board. Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 8: `sky` card **Files:** - Create: `widgets/sky.lua` - [ ] **Step 1: Write the widget** ```lua -- Sky: sunrise, sunset, day length and the moon, over the daylight arc. -- -- Adapted from the Conky-Mirage theme's sky tile by alexsson-xexpanderx -- (see lib/weather.lua). The arc is the one the old weather card drew, minus -- its time labels: the rows carry the times now. local card = require 'lib.card' local data = require 'lib.data' local weather = require 'lib.weather' local M = {} -- 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, rise, set, now) local pad = math.max(4, w * 0.012) local x0, x1 = x + pad, x + w - pad local base = y + h - pad local top = y + h * 0.08 -- Cubic Bezier control points, chosen so the curve peaks near the middle. local p0 = { x0, base } local c1 = { x0 + (x1 - x0) * 0.22, top - h * 0.14 } local c2 = { x1 - (x1 - x0) * 0.22, top - h * 0.14 } local p3 = { x1, base } -- 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) 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, rise, set) or 0 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 -- At night the dot is parked at an end, so it takes the dim colour: a bright -- dot on the horizon would read as a sun that is still up. card.rgba(cr, weather.is_day(now, rise, set) and colors.body or colors.label) cairo_new_path(cr) cairo_arc(cr, bez(p0[1], c1[1], c2[1], p3[1]), bez(p0[2], c1[2], c2[2], p3[2]), math.max(3, math.min(9, h * 0.045)), 0, math.pi * 2) cairo_fill(cr) end function M.draw(cr, rect, colors) local inner = card.card(cr, rect, colors) local w = weather.parse(data.slurp(weather.CACHE)) if not w then card.notice(cr, inner, colors, 'SKY', 'no weather data', 'bin/weather-fetch.sh') return end local now = os.time() local rise, set = w.sun.rise, w.sun.set local phase, lit = weather.moon(now) local rows = { { 'SUNRISE', os.date('%H:%M', rise) }, { 'SUNSET', os.date('%H:%M', set) }, { 'DAYLIGHT', weather.duration(set - rise) }, { 'MOON', weather.num(lit * 100, '%') }, } local y = card.standard(cr, inner, colors, { icon = card.ICON.sky, title = 'SKY', big = weather.moon_icon(phase), big_font = card.FONT_MONO, big_color = colors.heading, rows = rows, share = 0.65, }) -- An arch taller than about 0.6 of its width reads as a door, not a day. local arc_h = math.min(inner.y + inner.h - y, inner.w * 0.6) if arc_h >= 50 then draw_arc(cr, inner.x, inner.y + inner.h - arc_h, inner.w, arc_h, colors, rise, set, now) end end return M ``` `parse()` guarantees `w.sun.rise` and `w.sun.set` are numbers, so the `os.date` and subtraction calls are safe. - [ ] **Step 2: Render and look** Run: `lua test/render.lua sky 16 12 3.5 4.5 /tmp/claude-sky.png`, open it. Expected: `SKY` title with a moon glyph big at the right in the heading colour, four rows, the arc pinned to the bottom with the dot on it (dim at night). Also render at `2 3`: the arc drops out below 50px. - [ ] **Step 3: Commit** ```bash git add widgets/sky.lua git commit -m "feat(sky): sun, moon and daylight-arc card adapted from Conky-Mirage Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 9: Layout and live check **Files:** - Modify: `dashboard.lua` (the `layout` table) - [ ] **Step 1: Add the forecast rows** In `dashboard.lua`, add these entries at the end of the `layout` table, after the `immich` row: ```lua -- The forecast set, in the right side the quickshell moves left open. { widget = 'hourly', col = 10, row = 1, w = 7, h = 3 }, { widget = 'week', col = 10, row = 4, w = 3.5, h = 9 }, { widget = 'details', col = 13.5, row = 4, w = 3.5, h = 4.5 }, { widget = 'sky', col = 13.5, row = 8.5, w = 3.5, h = 4.5 }, ``` - [ ] **Step 2: Suite** Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua` Expected: all pass. - [ ] **Step 3: Restart and look at the live board** Run: `./restart.sh` Then show the `dash` special workspace and screenshot it (`grim` needs the active workspace; see the Hyprland memory note), and open the screenshot with Read. Check: no card overlaps another, type size reads consistently beside the clock and calendar, every card shows data (not its notice), no Lua error overlay. The screenshot shows calendar titles and photos: never commit or share it. If the user wants positions changed, edit only the four rows above and `./restart.sh` again (a `dashboard.lua` edit alone needs no restart, but a widget edit does). - [ ] **Step 4: Commit** ```bash git add dashboard.lua git commit -m "feat(layout): place the hourly, week, details and sky cards Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 10: Docs **Files:** - Modify: `README.md`, `DESIGN.md` - [ ] **Step 1: Rewrite README's Weather section** Replace everything from `## Weather` up to (not including) `## System widgets` with: ```markdown ## Weather Five cards cover the weather: `now` (current conditions), `hourly` (the next 24 hours), `week` (seven days), `details` (wind, humidity, UV, air quality) and `sky` (sun, moon and the daylight arc). The forecast cards are adapted from [Conky-Mirage](https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage) by alexsson-xexpanderx, redrawn in this dashboard's palette, glyphs and fonts. Data comes from [Open-Meteo](https://open-meteo.com), which needs no key. Set the location: cp weather.env.example ~/.config/udt/weather.env chmod 600 ~/.config/udt/weather.env That path is outside the repo because a location is personal data, and anything committed is potentially public. `weather.env` is gitignored here as a second line of defence. `CITY` is geocoded once through Open-Meteo and the result cached in `~/.cache/udt/weather-loc`; changing `CITY` or `COUNTRY` re-geocodes on the next fetch. Fetching is scheduled by conky itself: `conky.text` carries `${texeci 900 ~/.config/conky/bin/weather-fetch.sh}`, so the cache refreshes every 15 minutes while the dashboard runs and never while it does not. The cards only ever read `~/.cache/udt/weather.txt`; they make no network call and cannot block the draw. Until the first fetch lands, each card draws its own chrome with `no weather data` rather than vanishing. To check the fetch by hand: ./bin/weather-fetch.sh; echo "exit: $?" It prints nothing and exits 0 on success. On failure it says why on stderr and leaves any existing cache alone. A failed air-quality call is not a failure: the AQI row reads `--` and everything else updates. ``` - [ ] **Step 2: Fix the scattered mentions in README** Make these exact replacements: | Find | Replace with | |---|---| | `The key sits in a plain file, like the weather key, because the fetch runs` | `The key sits in a plain file because the fetch runs` | | ` { widget = 'weather', col = 1, row = 2, w = 1, h = 1 },` | ` { widget = 'now', col = 1, row = 2, w = 1, h = 1 },` | | `a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock and weather` / `cards do.` | `a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock does.` (joined onto one line) | | `The` / `weather card stacks from the top and lets the arc take the remainder.` | `The` / `sky card stacks its rows from the top and pins the arc to the bottom.` | | `overlap. The weather arc does this: below 70px it is not drawn at all, because a` / `curve crossing the stat rows is worse than no curve.` | `overlap. The sky arc does this: below 50px it is not drawn at all, because a` / `curve crossing the rows is worse than no curve.` | | ``~/.fonts/o/Oswald`) for squared numerals while the weather card keeps`` | ``~/.fonts/o/Oswald`) for squared numerals while the other cards keep`` | | The whole paragraph starting `**A bad OWM key returns 401 and an unknown city returns 404**` | `**A 200 is not proof of a forecast.** `curl -f` rejects HTTP errors, and the` / `fetch script also checks the flattened cache has a current temperature before` / `replacing the old one, so an unusable body never reaches the cards.` | | `weather text once shrank to a whisper beside the clock when its cell got shorter` | `old weather card's text once shrank to a whisper beside the clock when its cell got shorter` | - [ ] **Step 3: Fix DESIGN.md** | Find | Replace with | |---|---| | `card: the daylight arc in `weather`. Or the type itself fills the cell.` | `card: the daylight arc in `sky`, the curve in `hourly`. Or the type itself fills the cell.` | | `weather puts the condition glyph in its header, and every card title` | ``now` puts the condition glyph in its header, and every card title` | - [ ] **Step 4: Check nothing stale remains** Run: `grep -n -i "openweather\|OWM\|weather\.json\|widget = 'weather'" README.md DESIGN.md AGENTS.md lib widgets bin test dashboard.lua conky.conf.in` Expected: no output. Also `grep -c '—' README.md DESIGN.md` prints `0` for both. - [ ] **Step 5: Commit** ```bash git add README.md DESIGN.md git commit -m "docs: the Open-Meteo weather cards and their Conky-Mirage origin Co-Authored-By: Claude Opus 5.5 " ``` - [ ] **Step 6: Push** Run: `git push` (`origin` may fan out to several push URLs; that is expected.)