From 0d8ad4d94c7aa96bdb1becac0c0f9e43ffa77dd0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 18 Oct 2026 14:25:05 +0200 Subject: docs: implementation plan for the Mirage-derived weather cards Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-18-mirage-weather-cards.md | 1552 ++++++++++++++++++++ 1 file changed, 1552 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-18-mirage-weather-cards.md (limited to 'docs/superpowers/plans') diff --git a/docs/superpowers/plans/2026-10-18-mirage-weather-cards.md b/docs/superpowers/plans/2026-10-18-mirage-weather-cards.md new file mode 100644 index 0000000..2e044a5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-mirage-weather-cards.md @@ -0,0 +1,1552 @@ +# 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.) -- cgit v1.2.3