1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
|
# Weather Widget Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** One vertical dashboard card showing current weather on top and a sunrise-to-sunset arc at the bottom, fed by a cached OpenWeatherMap response.
**Architecture:** A shell script curls OWM into `~/.cache/udt/weather.json` on conky's own `${execi}` schedule. `lib/weather.lua` parses that file and owns the domain tables (condition ids, Beaufort, compass); `widgets/weather.lua` draws the card from the parsed table. The dashboard never touches the network.
**Tech Stack:** Lua 5.4 + Cairo (via conky's bindings), bash + curl for the fetch, `jq` only inside the fetch script. No JSON library: Lua patterns read the handful of fields the card draws.
**Spec:** `docs/superpowers/specs/2026-09-17-weather-widget-design.md`
---
## Critical platform facts
Read these before touching anything. Each one cost time to learn on this project.
- **A Lua error is a blank screen.** Conky reports a Lua fault on no stream. The `pcall` overlay in `dashboard.lua` is the only reason failures are visible. A parser that raises takes the whole dashboard down, so every parse path returns `nil` instead.
- **`conky_parse('${color3}')` returns an empty string.** Colours are parsed out of the file at `conky_config`. Widgets never read colours themselves; they use the `colors` table they are handed.
- **Never call `cairo_text_extents_t:create()` per call.** It leaks ~182KB per 5000 allocations and `collectgarbage()` does not reclaim it. `lib/card.lua` already owns one reused struct; use `card.measure()` and `card.advance()`.
- **`card.measure()` is ink width, `card.advance()` is cursor movement.** Stepping a cursor by ink width collapses spaces. Use `advance()` for runs of text, `measure()` only to centre or right-align.
- **`${execi}` fires even when it prints nothing** and even though `conky.text` is otherwise empty (verified 2026-09-17: a probe config ran the command twice over a 12s run at `execi 2`). This is what makes the fetch scheduling work.
- **Conky never rereads its config.** Any `conky.conf.in` change needs UDT's `install.sh` to re-render and restart conky.
- **`Inconsolata Nerd Font` has all the glyphs used here** (Weather Icons U+E3xx plus U+E34C/U+E34D). Verified by rendering. A missing glyph would be an invisible blank, not an error.
- **The API key may still be inactive.** A new OWM key returns `401 Invalid API key` for minutes to hours after creation. As of this plan's writing the key in `~/.config/udt/weather.env` still returns 401, which is expected and not a bug in this code. Every task below is testable without a working key; Task 8 is where a live key finally matters.
---
## File structure
| Path | Responsibility |
|---|---|
| `bin/weather-fetch.sh` | Fetch and cache. Knows the API and the env file; knows nothing about drawing. |
| `lib/weather.lua` | Parse the cache; condition/Beaufort/compass tables; sun position maths. Pure functions over strings and numbers. |
| `widgets/weather.lua` | Draw the card. Knows the layout bands; knows nothing about the API. |
| `test/test_weather.lua` | Checks for everything in `lib/weather.lua`. |
| `test/fixtures/weather.json` | A real-shaped OWM response with placeholder key and city. |
| `test/fixtures/weather_truncated.json` | A half-written response, for the atomicity failure mode. |
| `weather.env.example` | Placeholder config, committed. The real file is gitignored. |
Modified: `conky.conf.in` (the `execi` line), `dashboard.lua` (one layout row), `README.md`, and UDT's `install.sh` (symlink `bin/`, silence its conky restart).
---
## Task 1: Condition id to icon
**Files:**
- Create: `lib/weather.lua`
- Create: `test/test_weather.lua`
- [ ] **Step 1: Write the failing test**
Create `test/test_weather.lua`:
```lua
-- Checks for lib/weather.lua.
-- Run from the repo root: lua test/test_weather.lua
-- The domain tables are what silently misreport when a boundary is off by one,
-- so every range boundary gets an assertion. The card itself is verified by
-- screenshot.
package.path = './?.lua;' .. package.path
local weather = require 'lib.weather'
-- === Condition id to icon =================================================
-- OWM ids group by hundreds, but the boundaries are not round numbers: the
-- ranges come from the polybar script that ran against this API for years.
-- Each assertion pairs the last id in a range with the first id of the next,
-- which is where an off-by-one would hide.
-- Sunrise 1500, sunset 1900, so DAY must sit INSIDE that window and NIGHT
-- outside it. Naming them without checking them against the window is how the
-- first draft of this test made every timestamp evaluate as night.
local DAY, NIGHT = 1700, 2000
local function icon(id, now)
return weather.icon(id, now, 1500, 1900) -- sunrise 1500, sunset 1900
end
-- Thunderstorm: everything up to 232.
assert(icon(200, DAY) == weather.ICON.thunder_day, 'id 200 day')
assert(icon(232, DAY) == weather.ICON.thunder_day, 'id 232 is still thunder')
assert(icon(232, NIGHT) == weather.ICON.thunder_night, 'id 232 night')
-- Light drizzle: 233..311.
assert(icon(233, DAY) == weather.ICON.drizzle_day, 'id 233 leaves thunder')
assert(icon(311, DAY) == weather.ICON.drizzle_day, 'id 311 is still light drizzle')
-- Heavy drizzle: 312..321.
assert(icon(312, DAY) == weather.ICON.drizzle_heavy_day, 'id 312 is heavy drizzle')
assert(icon(321, DAY) == weather.ICON.drizzle_heavy_day, 'id 321 is still heavy drizzle')
-- Rain: 322..531.
assert(icon(322, DAY) == weather.ICON.rain_day, 'id 322 is rain')
assert(icon(531, DAY) == weather.ICON.rain_day, 'id 531 is still rain')
-- Snow: 532..622. One icon, no day/night variant.
assert(icon(600, DAY) == weather.ICON.snow, 'id 600 is snow')
assert(icon(622, NIGHT) == weather.ICON.snow, 'snow is the same at night')
-- Fog: 623..771.
assert(icon(741, DAY) == weather.ICON.fog, 'id 741 is fog')
assert(icon(771, DAY) == weather.ICON.fog, 'id 771 is still fog')
-- Tornado is a single id, not a range.
assert(icon(781, DAY) == weather.ICON.tornado, 'id 781 is tornado')
-- Clear and few clouds each have a day and a night face.
assert(icon(800, DAY) == weather.ICON.clear_day, 'id 800 day is the sun')
assert(icon(800, NIGHT) == weather.ICON.clear_night, 'id 800 night is the moon')
assert(icon(801, DAY) == weather.ICON.few_day, 'id 801 day')
assert(icon(801, NIGHT) == weather.ICON.few_night, 'id 801 night')
-- Overcast: 802..804, no night variant.
assert(icon(804, DAY) == weather.ICON.overcast, 'id 804 is overcast')
-- Anything outside the known ids must be visibly wrong, not silently sunny.
assert(icon(999, DAY) == weather.ICON.unknown, 'unknown id gets the error glyph')
assert(weather.icon(nil, DAY, 1500, 1900) == weather.ICON.unknown, 'nil id')
print('test_weather: all assertions passed')
```
- [ ] **Step 2: Run test to verify it fails**
Run: `lua test/test_weather.lua`
Expected: FAIL with `module 'lib.weather' not found`
- [ ] **Step 3: Write minimal implementation**
Create `lib/weather.lua`:
```lua
-- OpenWeatherMap domain knowledge and cache parsing.
--
-- Separate from lib/data.lua: that reads /proc and /sys, this reads a cached
-- HTTP response and carries its own tables. Same testable shape though, every
-- function takes a string or numbers and returns a value, so the tests need no
-- filesystem and no network.
--
-- Nothing here raises. A Lua error in this project is a blank screen, so every
-- parse path returns nil and lets the widget draw its "no data" state.
local M = {}
-- Nerd Font codepoints, all present in Inconsolata Nerd Font (card.FONT_MONO).
-- Verified by rendering the glyphs and looking at them, not by fontconfig
-- alone: a missing glyph draws as an invisible blank rather than an error.
M.ICON = {
thunder_day = '\u{E30F}',
thunder_night = '\u{E32A}',
drizzle_day = '\u{E306}',
drizzle_night = '\u{E326}',
drizzle_heavy_day = '\u{E308}',
drizzle_heavy_night = '\u{E325}',
rain_day = '\u{E308}',
rain_night = '\u{E325}',
snow = '\u{E31A}',
fog = '\u{E313}',
tornado = '\u{E351}',
clear_day = '\u{E30D}',
clear_night = '\u{E32B}',
few_day = '\u{E302}',
few_night = '\u{E379}',
overcast = '\u{E312}',
unknown = '\u{E374}',
sunrise = '\u{E34C}',
sunset = '\u{E34D}',
}
-- Condition id to icon, by upper bound. Ported from the polybar script's
-- accumulated knowledge; the ranges are not round hundreds.
-- `now`, `sunrise` and `sunset` are unix timestamps and pick the day or night
-- face for the conditions that have both.
function M.icon(id, now, sunrise, sunset)
if type(id) ~= 'number' then return M.ICON.unknown end
-- `now` is guarded alongside sunrise and sunset, not just them: comparing a
-- nil now against a number raises, and a raise here is a blank dashboard.
-- Missing any of the three means the sun is unknown, so the day face is
-- drawn, which is what is_day() falls back to as well.
local day = true
if now and sunrise and sunset then day = (now >= sunrise and now <= sunset) end
local function pick(d, n) return day and d or n end
if id <= 232 then return pick(M.ICON.thunder_day, M.ICON.thunder_night)
elseif id <= 311 then return pick(M.ICON.drizzle_day, M.ICON.drizzle_night)
elseif id <= 321 then return pick(M.ICON.drizzle_heavy_day, M.ICON.drizzle_heavy_night)
elseif id <= 531 then return pick(M.ICON.rain_day, M.ICON.rain_night)
elseif id <= 622 then return M.ICON.snow
elseif id <= 771 then return M.ICON.fog
elseif id == 781 then return M.ICON.tornado
elseif id == 800 then return pick(M.ICON.clear_day, M.ICON.clear_night)
elseif id == 801 then return pick(M.ICON.few_day, M.ICON.few_night)
elseif id <= 804 then return M.ICON.overcast
end
return M.ICON.unknown
end
return M
```
- [ ] **Step 4: Run test to verify it passes**
Run: `lua test/test_weather.lua`
Expected: PASS, printing `test_weather: all assertions passed`
- [ ] **Step 5: Verify the glyphs actually render**
The test proves the mapping, not that the codepoints are real glyphs. Render them once and look:
```bash
f=$(fc-match -f '%{file}' 'Inconsolata Nerd Font')
magick -background '#0d1b26' -fill '#c5d8e3' -font "$f" -pointsize 54 \
label:$' ' \
/tmp/wicons.png
```
Open `/tmp/wicons.png`. Expected, in order: thunder day, thunder night,
drizzle day, drizzle night, rain day, rain night, snow (a cloud with
snowflakes, no sun), fog, tornado, clear day, clear night (a bare crescent),
few clouds day, few clouds night, overcast, an "N/A" box, sunrise, sunset.
All seventeen were rendered and inspected while this plan was written, so they
are known good. Look anyway: a wrong-but-present codepoint draws a plausible
neighbouring glyph rather than failing, which is how `E30A` was caught standing
in for snow with a sun-and-rain icon. A blank means the codepoint is absent
entirely.
- [ ] **Step 6: Commit**
```bash
git add lib/weather.lua test/test_weather.lua
git commit -m "feat: add OWM condition id to icon mapping"
```
---
## Task 2: Wind, Beaufort and compass
**Files:**
- Modify: `lib/weather.lua`
- Modify: `test/test_weather.lua`
- [ ] **Step 1: Write the failing test**
Insert into `test/test_weather.lua`, immediately before the final `print` line:
```lua
-- === Wind =================================================================
-- OWM metric gives m/s; the card shows km/h. The conversion is where a wrong
-- factor would look plausible but read 3.6x off.
assert(weather.kmh(10) == 36, '10 m/s is 36 km/h')
assert(weather.kmh(0) == 0, 'calm')
assert(weather.kmh(nil) == nil, 'missing wind speed stays missing')
-- Beaufort thresholds, in km/h, from the polybar script. Each assertion sits
-- on a boundary and just past it, which is where an inclusive/exclusive
-- mistake hides.
assert(weather.beaufort(0) == 0, 'calm is force 0')
assert(weather.beaufort(1) == 0, '1 km/h is still force 0')
assert(weather.beaufort(2) == 1, 'just over 1 is force 1')
assert(weather.beaufort(5) == 1, '5 is still force 1')
assert(weather.beaufort(6) == 2, 'just over 5 is force 2')
assert(weather.beaufort(11) == 2, '11 is still force 2')
assert(weather.beaufort(12) == 3, 'just over 11 is force 3')
assert(weather.beaufort(19) == 3, '19 is still force 3')
assert(weather.beaufort(28) == 4, '28 is force 4')
assert(weather.beaufort(38) == 5, '38 is force 5')
assert(weather.beaufort(49) == 6, '49 is force 6')
assert(weather.beaufort(61) == 7, '61 is force 7')
assert(weather.beaufort(74) == 8, '74 is force 8')
assert(weather.beaufort(88) == 9, '88 is force 9')
assert(weather.beaufort(102) == 10, '102 is force 10')
assert(weather.beaufort(117) == 11, '117 is force 11')
assert(weather.beaufort(118) == 12, 'over 117 is hurricane force')
-- Compass: eight points, each spanning 45 degrees, offset by half a step so
-- north straddles 0 rather than starting at it. The wraparound is the case
-- that a naive floor(deg/45) gets wrong.
assert(weather.compass(0) == 'N', '0 is north')
assert(weather.compass(360) == 'N', '360 wraps to north')
assert(weather.compass(11) == 'N', 'just under the NE boundary')
assert(weather.compass(349) == 'N', 'just over the NW boundary wraps to north')
assert(weather.compass(45) == 'NE', '45 is northeast')
assert(weather.compass(90) == 'E', '90 is east')
assert(weather.compass(135) == 'SE', '135 is southeast')
assert(weather.compass(180) == 'S', '180 is south')
assert(weather.compass(225) == 'SW', '225 is southwest')
assert(weather.compass(270) == 'W', '270 is west')
assert(weather.compass(315) == 'NW', '315 is northwest')
assert(weather.compass(nil) == nil, 'missing direction stays missing')
-- The arrow points where the wind is going, and OWM reports where it comes
-- from, so a north wind draws a downward arrow.
assert(weather.arrow(0) == '\u{2193}', 'a north wind blows southward')
assert(weather.arrow(180) == '\u{2191}', 'a south wind blows northward')
assert(weather.arrow(nil) == '', 'no direction draws nothing')
```
- [ ] **Step 2: Run test to verify it fails**
Run: `lua test/test_weather.lua`
Expected: FAIL with `attempt to call a nil value (field 'kmh')`
- [ ] **Step 3: Write minimal implementation**
Add to `lib/weather.lua`, before the final `return M`:
```lua
-- OWM's metric units report wind in m/s. The card shows km/h.
function M.kmh(ms)
if type(ms) ~= 'number' then return nil end
return ms * 3.6
end
-- Beaufort force from km/h. Upper bounds carried over from the polybar
-- script; a value equal to a bound stays in the lower force.
--
-- The scale starts at 0 and ipairs starts at 1, so force N's upper bound is
-- BEAUFORT[N + 1] and the loop index is already N + 1. Hence `force - 1`
-- below. The scale tops out at 12, which is why the fall-through returns it.
local BEAUFORT = { 1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117 }
function M.beaufort(kmh)
if type(kmh) ~= 'number' then return nil end
for force, bound in ipairs(BEAUFORT) do
if kmh <= bound then return force - 1 end
end
return 12
end
-- Eight compass points. The half-step offset is what makes north straddle
-- zero: without it, 350 degrees would land in NW and 10 in NE, leaving north
-- with only half its arc.
--
-- Nothing calls compass() yet: the card draws arrow() instead. It is kept
-- because it shares arrow()'s binning exactly, so testing both pins that
-- shared logic from two angles, and swapping the card to read "S 9 km/h"
-- instead of an arrow is then a one-word change rather than new code.
local POINTS = { 'N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' }
function M.compass(deg)
if type(deg) ~= 'number' then return nil end
local i = math.floor(((deg % 360) + 22.5) / 45) % 8
return POINTS[i + 1]
end
-- Arrow glyphs, in the same order as POINTS but rotated half a turn: OWM
-- reports the direction the wind comes FROM, and an arrow reads as the
-- direction it goes TO.
local ARROWS = { '\u{2193}', '\u{2199}', '\u{2190}', '\u{2196}',
'\u{2191}', '\u{2197}', '\u{2192}', '\u{2198}' }
function M.arrow(deg)
if type(deg) ~= 'number' then return '' end
local i = math.floor(((deg % 360) + 22.5) / 45) % 8
return ARROWS[i + 1]
end
```
- [ ] **Step 4: Run test to verify it passes**
Run: `lua test/test_weather.lua`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/weather.lua test/test_weather.lua
git commit -m "feat: add wind conversion, Beaufort and compass tables"
```
---
## Task 3: Sun position along the arc
**Files:**
- Modify: `lib/weather.lua`
- Modify: `test/test_weather.lua`
- [ ] **Step 1: Write the failing test**
Insert into `test/test_weather.lua`, immediately before the final `print` line:
```lua
-- === Sun position =========================================================
-- t is the fraction of daylight elapsed, and the widget uses it to place the
-- dot along the arc. Sunrise 1000, sunset 2000, so midday is 1500.
assert(weather.sun_t(1000, 1000, 2000) == 0, 'at sunrise t is 0')
assert(weather.sun_t(1500, 1000, 2000) == 0.5, 'at midday t is half')
assert(weather.sun_t(2000, 1000, 2000) == 1, 'at sunset t is 1')
-- OWM reports today's sunrise and sunset, so between midnight and sunrise the
-- numerator is negative. The clamp is the whole handling; without it the dot
-- would be drawn off the left end of the arc.
assert(weather.sun_t(500, 1000, 2000) == 0, 'before dawn clamps to 0')
assert(weather.sun_t(9999, 1000, 2000) == 1, 'after dusk clamps to 1')
-- Degenerate input must not divide by zero.
assert(weather.sun_t(1500, 2000, 2000) == 0, 'zero-length day gives 0, not nan')
assert(weather.sun_t(nil, 1000, 2000) == nil, 'missing now gives nil')
assert(weather.sun_t(1500, nil, 2000) == nil, 'missing sunrise gives nil')
-- is_day drives both the icon face and the dot's colour: a dot parked at the
-- end of the arc must not read as a sun that is still up.
assert(weather.is_day(1500, 1000, 2000) == true, 'midday is day')
assert(weather.is_day(1000, 1000, 2000) == true, 'sunrise counts as day')
assert(weather.is_day(2000, 1000, 2000) == true, 'sunset counts as day')
assert(weather.is_day(500, 1000, 2000) == false, 'before dawn is night')
assert(weather.is_day(2500, 1000, 2000) == false, 'after dusk is night')
```
- [ ] **Step 2: Run test to verify it fails**
Run: `lua test/test_weather.lua`
Expected: FAIL with `attempt to call a nil value (field 'sun_t')`
- [ ] **Step 3: Write minimal implementation**
Add to `lib/weather.lua`, before the final `return M`:
```lua
-- Fraction of daylight elapsed, clamped to 0..1.
--
-- OWM returns TODAY's sunrise and sunset, so before dawn this is negative and
-- after dusk it is over 1. Clamping parks the dot at the corresponding end of
-- the arc, which is the cheap correct-enough behaviour; multi-day astronomy
-- buys nothing for a dot on a curve.
function M.sun_t(now, sunrise, sunset)
if type(now) ~= 'number' or type(sunrise) ~= 'number'
or type(sunset) ~= 'number' then return nil end
local span = sunset - sunrise
if span <= 0 then return 0 end -- polar day/night or bad data: no division
-- ponytail: a NaN timestamp slips through, since NaN is a number and every
-- comparison against it is false, so both clamps below fall through and this
-- returns NaN. Unreachable today: parse() matches %d+ for the sun times and
-- `now` is os.time(). Cairo ignores a NaN coordinate, so the cost would be a
-- missing dot, not a crash. Guard it here if a caller ever computes `now`.
local t = (now - sunrise) / span
if t < 0 then return 0 elseif t > 1 then return 1 end
return t
end
-- Whether the sun is up. Inclusive at both ends, matching the polybar script's
-- comparison, so the instant of sunrise draws the day face.
function M.is_day(now, sunrise, sunset)
if type(now) ~= 'number' or type(sunrise) ~= 'number'
or type(sunset) ~= 'number' then return true end
return now >= sunrise and now <= sunset
end
```
- [ ] **Step 4: Run test to verify it passes**
Run: `lua test/test_weather.lua`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/weather.lua test/test_weather.lua
git commit -m "feat: add sun position along the daylight arc"
```
---
## Task 4: Parse the cached response
**Files:**
- Modify: `lib/weather.lua`
- Modify: `test/test_weather.lua`
- Create: `test/fixtures/weather.json`
- Create: `test/fixtures/weather_truncated.json`
- [ ] **Step 1: Create the fixtures**
Create `test/fixtures/weather.json`. This is a real OWM response shape with the
location replaced by a placeholder, since no real location belongs in committed
files:
```json
{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"base":"stations","main":{"temp":18.34,"feels_like":18.11,"temp_min":16.67,"temp_max":19.44,"pressure":1014,"humidity":72},"visibility":10000,"wind":{"speed":4.12,"deg":230},"clouds":{"all":75},"dt":1789625000,"sys":{"type":2,"id":2004688,"country":"XX","sunrise":1789600000,"sunset":1789646000},"timezone":7200,"id":1,"name":"Example City","cod":200}
```
Create `test/fixtures/weather_truncated.json`, a response cut mid-write, which
is what an unprotected cache would hand the parser:
```json
{"coord":{"lon":11.0,"lat":45.0},"weather":[{"id":501,"main":"Rain","descrip
```
- [ ] **Step 2: Write the failing test**
Insert into `test/test_weather.lua`, immediately before the final `print` line:
```lua
-- === Parsing the cache ====================================================
-- The parser reads scalars by key with Lua patterns rather than pulling in a
-- JSON library: the response is flat and known, and six numbers do not justify
-- a dependency. Caching the whole response still costs nothing, since a field
-- added later is already on disk.
local function read(path)
local f = assert(io.open(path, 'r'))
local s = f:read('*a')
f:close()
return s
end
local w = weather.parse(read('test/fixtures/weather.json'))
assert(w, 'the fixture must parse')
assert(w.id == 501, 'condition id, got ' .. tostring(w.id))
assert(w.description == 'moderate rain', 'description, got ' .. tostring(w.description))
assert(w.temp == 18.34, 'temp, got ' .. tostring(w.temp))
assert(w.feels_like == 18.11, 'feels like, got ' .. tostring(w.feels_like))
assert(w.humidity == 72, 'humidity, got ' .. tostring(w.humidity))
assert(w.wind_speed == 4.12, 'wind m/s, got ' .. tostring(w.wind_speed))
assert(w.wind_deg == 230, 'wind direction, got ' .. tostring(w.wind_deg))
assert(w.sunrise == 1789600000, 'sunrise, got ' .. tostring(w.sunrise))
assert(w.sunset == 1789646000, 'sunset, got ' .. tostring(w.sunset))
assert(w.city == 'Example City', 'city, got ' .. tostring(w.city))
assert(w.dt == 1789625000, 'observation time, got ' .. tostring(w.dt))
-- temp comes before feels_like in the response and both live under "main",
-- so a lazy pattern would read one for the other. They differ in the fixture
-- precisely so this is checkable.
assert(w.temp ~= w.feels_like, 'temp and feels_like must not collapse')
-- Failure modes all return nil rather than raising: a Lua error here is a
-- blank dashboard, not a message.
assert(weather.parse(read('test/fixtures/weather_truncated.json')) == nil,
'a truncated response must give nil')
assert(weather.parse('') == nil, 'empty input gives nil')
assert(weather.parse('not json at all') == nil, 'garbage gives nil')
assert(weather.parse(nil) == nil, 'nil input gives nil')
-- An error body carries cod 401 and no weather. It must not parse as data.
assert(weather.parse('{"cod":401,"message":"Invalid API key."}') == nil,
'an API error body must give nil')
```
- [ ] **Step 3: Run test to verify it fails**
Run: `lua test/test_weather.lua`
Expected: FAIL with `attempt to call a nil value (field 'parse')`
- [ ] **Step 4: Write minimal implementation**
Add to `lib/weather.lua`, before the final `return M`:
```lua
-- Parse the cached OWM response.
--
-- Lua patterns, not a JSON library: the current-weather response is flat and
-- its shape is known, so six scalars do not justify a dependency. Returns nil
-- on anything unreadable, never an error.
function M.parse(src)
if type(src) ~= 'string' or src == '' then return nil end
-- Scalars are matched inside their own object where the key would otherwise
-- be ambiguous. "temp" appears as a prefix of "temp_min" and "temp_max", so
-- it is anchored to the character that follows it.
local function num(pat)
return tonumber(src:match(pat))
end
local main = src:match('"main"%s*:%s*(%b{})') or ''
local wind = src:match('"wind"%s*:%s*(%b{})') or ''
local sys = src:match('"sys"%s*:%s*(%b{})') or ''
local cond = src:match('"weather"%s*:%s*%[%s*(%b{})') or ''
local w = {
id = tonumber(cond:match('"id"%s*:%s*(%-?%d+)')),
description = cond:match('"description"%s*:%s*"([^"]*)"'),
temp = tonumber(main:match('"temp"%s*:%s*(%-?[%d%.]+)')),
feels_like = tonumber(main:match('"feels_like"%s*:%s*(%-?[%d%.]+)')),
humidity = tonumber(main:match('"humidity"%s*:%s*(%d+)')),
wind_speed = tonumber(wind:match('"speed"%s*:%s*([%d%.]+)')),
wind_deg = tonumber(wind:match('"deg"%s*:%s*(%d+)')),
sunrise = tonumber(sys:match('"sunrise"%s*:%s*(%d+)')),
sunset = tonumber(sys:match('"sunset"%s*:%s*(%d+)')),
city = src:match('"name"%s*:%s*"([^"]*)"'),
dt = num('"dt"%s*:%s*(%d+)'),
}
-- Without these the card has nothing to draw, and a partial card is worse
-- than an honest "no data". An API error body fails here too: it carries a
-- cod and a message, no weather.
if not (w.id and w.temp and w.sunrise and w.sunset) then return nil end
return w
end
```
- [ ] **Step 5: Run test to verify it passes**
Run: `lua test/test_weather.lua`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add lib/weather.lua test/test_weather.lua test/fixtures/weather.json test/fixtures/weather_truncated.json
git commit -m "feat: parse the cached OWM response"
```
---
## Task 5: Staleness
**Files:**
- Modify: `lib/weather.lua`
- Modify: `test/test_weather.lua`
- [ ] **Step 1: Write the failing test**
Insert into `test/test_weather.lua`, immediately before the final `print` line:
```lua
-- === Staleness ============================================================
-- 45 minutes is three missed fetches at the 15-minute interval, so one
-- transient failure does not flag the card.
assert(weather.is_stale(1000, 1000) == false, 'a fresh reading is not stale')
assert(weather.is_stale(1000, 1000 + 45 * 60) == false, 'exactly 45 min is the boundary')
assert(weather.is_stale(1000, 1000 + 45 * 60 + 1) == true, 'past 45 min is stale')
assert(weather.is_stale(nil, 1000) == true, 'no observation time counts as stale')
-- The age string is what the card prints beside the city, so it must be short.
assert(weather.age_str(1000, 1000 + 90) == '1m', 'ninety seconds reads as 1m')
assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h')
assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours')
assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad')
```
- [ ] **Step 2: Run test to verify it fails**
Run: `lua test/test_weather.lua`
Expected: FAIL with `attempt to call a nil value (field 'is_stale')`
- [ ] **Step 3: Write minimal implementation**
Add to `lib/weather.lua`, before the final `return M`:
```lua
-- Three missed fetches at the 15-minute interval.
M.STALE_AFTER = 45 * 60
function M.is_stale(dt, now)
if type(dt) ~= 'number' then return true end
return (now - dt) > M.STALE_AFTER
end
-- Short age for the marker beside the city: "12m", "3h", "2d".
function M.age_str(dt, now)
if type(dt) ~= 'number' then return '?' end
local s = now - dt
if s < 3600 then return math.floor(s / 60) .. 'm' end
if s < 86400 then return math.floor(s / 3600) .. 'h' end
return math.floor(s / 86400) .. 'd'
end
```
- [ ] **Step 4: Run test to verify it passes**
Run: `lua test/test_weather.lua`
Expected: PASS
- [ ] **Step 5: Run the whole suite**
Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
Expected: three "all assertions passed" lines
- [ ] **Step 6: Commit**
```bash
git add lib/weather.lua test/test_weather.lua
git commit -m "feat: add cache staleness classification"
```
---
## Task 6: The fetch script
**Files:**
- Create: `bin/weather-fetch.sh`
- Create: `weather.env.example`
- [ ] **Step 1: Write the example config**
Create `weather.env.example`:
```bash
# Copy to ~/.config/udt/weather.env and fill in. That path is outside this
# repo on purpose: it holds a key, and anything committed is potentially
# public. chmod 600 it.
#
# Get a key at https://openweathermap.org/api. A new key returns
# "401 Invalid API key" for minutes to hours after creation; that is the API
# activating it, not a mistake in this file.
KEY=your_32_character_api_key_here
CITY=Your City
COUNTRY=XX
UNITS=metric
```
- [ ] **Step 2: Write the fetch script**
Create `bin/weather-fetch.sh`:
```bash
#!/bin/bash
# Fetch current weather into a cache file. Run from conky's ${execi}, so it
# prints nothing on success and never blocks the dashboard: the widget only
# ever reads the cache.
#
# Exits non-zero with a message on stderr when it cannot fetch, and leaves any
# existing cache untouched rather than replacing good data with an error.
set -u
ENV_FILE="${WEATHER_ENV:-$HOME/.config/udt/weather.env}"
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
CACHE="$CACHE_DIR/weather.json"
if [ ! -r "$ENV_FILE" ]; then
echo "weather-fetch: no $ENV_FILE (copy weather.env.example)" >&2
exit 1
fi
# shellcheck source=/dev/null
set -a; . "$ENV_FILE"; set +a
if [ -z "${KEY:-}" ]; then
echo "weather-fetch: KEY is empty in $ENV_FILE" >&2
exit 1
fi
mkdir -p "$CACHE_DIR"
# The temp file sits in the same directory as the target, because rename is
# only atomic within a filesystem. The widget reads this cache on its own 2s
# cadence, so a fetch killed mid-write would otherwise hand it half a response.
TMP="$CACHE.tmp.$$"
trap 'rm -f "$TMP"' EXIT
URL="https://api.openweathermap.org/data/2.5/weather"
if ! curl -fsS --max-time 15 --get "$URL" \
--data-urlencode "appid=$KEY" \
--data-urlencode "units=${UNITS:-metric}" \
--data-urlencode "lang=${LANG_CODE:-en}" \
--data-urlencode "q=${CITY},${COUNTRY}" \
-o "$TMP" 2>/dev/null; then
echo "weather-fetch: request failed" >&2
exit 1
fi
# A bad key returns HTTP 200 with a JSON error body, so the status code alone
# proves nothing. This check is carried over from the polybar script, which
# learned it the same way.
if [ "$(jq -r '.cod // empty' "$TMP" 2>/dev/null)" != "200" ]; then
echo "weather-fetch: API error: $(jq -r '.message // "unknown"' "$TMP" 2>/dev/null)" >&2
exit 1
fi
mv -f "$TMP" "$CACHE"
```
- [ ] **Step 3: Make it executable and check it with shellcheck**
```bash
# from the repo root
chmod +x bin/weather-fetch.sh
shellcheck bin/weather-fetch.sh
```
Expected: no output. If `shellcheck` is not installed, skip it.
- [ ] **Step 4: Verify the three failure paths**
Each must fail loudly and leave the cache alone.
```bash
# from the repo root
# Missing env file
WEATHER_ENV=/nonexistent ./bin/weather-fetch.sh; echo "exit: $?"
```
Expected: `weather-fetch: no /nonexistent (copy weather.env.example)` and `exit: 1`
```bash
# Empty key
printf 'KEY=\nCITY=X\nCOUNTRY=XX\nUNITS=metric\n' > /tmp/empty.env
WEATHER_ENV=/tmp/empty.env ./bin/weather-fetch.sh; echo "exit: $?"
```
Expected: `weather-fetch: KEY is empty in /tmp/empty.env` and `exit: 1`
```bash
# Bad key: HTTP 200 with an error body, the case the .cod check exists for
printf 'KEY=%s\nCITY=London\nCOUNTRY=GB\nUNITS=metric\n' \
00000000000000000000000000000000 > /tmp/bad.env
WEATHER_ENV=/tmp/bad.env ./bin/weather-fetch.sh; echo "exit: $?"
ls ~/.cache/udt/weather.json 2>/dev/null || echo "cache correctly not created"
rm -f /tmp/empty.env /tmp/bad.env
```
Expected: an `API error` message, `exit: 1`, and no cache file written.
- [ ] **Step 5: Verify the real key, which may not be active yet**
```bash
./bin/weather-fetch.sh; echo "exit: $?"
```
Two acceptable outcomes:
- `exit: 0` and `~/.cache/udt/weather.json` exists: the key is live, continue.
- `weather-fetch: API error: Invalid API key.` and `exit: 1`: the key is still
activating. **This is not a defect in this task.** Confirm the script's
behaviour is right (it printed a message and wrote no cache) and continue;
Task 8 retries with a live key.
- [ ] **Step 6: Commit**
```bash
git add bin/weather-fetch.sh weather.env.example
git commit -m "feat: add the weather fetch script"
```
---
## Task 7: The card
**Files:**
- Create: `widgets/weather.lua`
- Modify: `dashboard.lua` (the `layout` table)
- Modify: `conky.conf.in` (the `conky.text` block)
- [ ] **Step 1: Write the widget**
Create `widgets/weather.lua`:
```lua
-- Weather: current conditions over a sunrise-to-sunset arc.
--
-- Shape follows idea1.png, which splits this across two cards; this merges
-- them into one vertical cell. Every colour comes from the palette, every size
-- derives from the rect, so the card composes on either monitor.
local card = require 'lib.card'
local weather = require 'lib.weather'
local M = {}
local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
.. '/udt/weather.json'
-- Draw the card chrome with a message in it. Used for every no-data state, so
-- the dashboard keeps its shape instead of showing an empty cell, which is
-- indistinguishable from a crashed widget.
local function draw_notice(cr, inner, colors, line1, line2)
card.font(cr, card.FONT_UI, 15, true)
card.rgba(cr, colors.label)
card.text(cr, inner.x, inner.y + 24, line1)
card.font(cr, card.FONT_MONO, 12, false)
card.text(cr, inner.x, inner.y + 48, line2)
end
-- The daylight arc: a curve, its baseline, the sun, and the two times.
--
-- The dot is placed by evaluating the curve at t rather than by computing a
-- point on a circle: the curve IS the path, so evaluating it keeps the dot on
-- the arc if the control points are ever adjusted.
local function draw_arc(cr, x, y, w, h, colors, w_data, now)
local pad = 4
local x0, x1 = x + pad, x + w - pad
-- Room under the curve for the time labels.
local base = y + h - 20
local top = y + 6
-- Cubic Bezier control points, chosen so the curve peaks near the middle at
-- about two thirds of the band height.
local p0 = { x0, base }
local c1 = { x0 + (x1 - x0) * 0.22, top - 10 }
local c2 = { x1 - (x1 - x0) * 0.22, top - 10 }
local p3 = { x1, base }
-- The baseline: the horizon the sun rises from and sets into.
card.rgba(cr, colors.rule, 0.6)
cairo_set_line_width(cr, 1)
cairo_move_to(cr, x0, base)
cairo_line_to(cr, x1, base)
cairo_stroke(cr)
-- The arc itself.
card.rgba(cr, colors.rule, 0.9)
cairo_set_line_width(cr, 1.5)
cairo_move_to(cr, p0[1], p0[2])
cairo_curve_to(cr, c1[1], c1[2], c2[1], c2[2], p3[1], p3[2])
cairo_stroke(cr)
local t = weather.sun_t(now, w_data.sunrise, w_data.sunset) or 0
local day = weather.is_day(now, w_data.sunrise, w_data.sunset)
-- Evaluate the cubic at t.
local function bez(a, b, c, d)
local u = 1 - t
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
end
local sx = bez(p0[1], c1[1], c2[1], p3[1])
local sy = bez(p0[2], c1[2], c2[2], p3[2])
-- At night the dot is parked at an end, so it takes the dim colour: a bright
-- dot sitting on the horizon would read as a sun that is still up.
card.rgba(cr, day and colors.body or colors.label, 1)
cairo_arc(cr, sx, sy, 5, 0, math.pi * 2)
cairo_fill(cr)
-- The times, each behind its own glyph, at the ends of the arc.
card.font(cr, card.FONT_MONO, 12, false)
card.rgba(cr, colors.label)
local ty = y + h - 2
card.text(cr, x0, ty,
weather.ICON.sunrise .. ' ' .. os.date('%H:%M', w_data.sunrise))
card.text_right(cr, x1, ty,
weather.ICON.sunset .. ' ' .. os.date('%H:%M', w_data.sunset))
end
function M.draw(cr, rect, colors)
local inner = card.card(cr, rect, colors)
local now = os.time()
local src = nil
local f = io.open(CACHE, 'r')
if f then src = f:read('*a'); f:close() end
local w = weather.parse(src)
if not w then
if io.open(os.getenv('HOME') .. '/.config/udt/weather.env', 'r') then
draw_notice(cr, inner, colors, 'no weather data', 'waiting for first fetch')
else
draw_notice(cr, inner, colors, 'no weather data', 'set ~/.config/udt/weather.env')
end
return
end
local y = inner.y
-- Header: condition glyph, then the temperature with the city beneath it.
local glyph = weather.icon(w.id, now, w.sunrise, w.sunset)
card.font(cr, card.FONT_MONO, 36, false)
card.rgba(cr, colors.highlight)
card.text(cr, inner.x, y + 34, glyph)
local gx = inner.x + card.advance(cr, glyph) + 14
card.font(cr, card.FONT_HEAVY, 44, false)
card.rgba(cr, colors.body)
card.text(cr, gx, y + 38, string.format('%d\u{00B0}', math.floor(w.temp + 0.5)))
card.font(cr, card.FONT_MONO, 11, false)
card.rgba(cr, colors.label)
local city = (w.city or ''):upper()
if weather.is_stale(w.dt, now) then
city = city .. ' stale ' .. weather.age_str(w.dt, now)
end
card.text(cr, gx, y + 56, city)
-- Condition, in OWM's own words, first letter capitalised.
card.font(cr, card.FONT_UI, 13, false)
card.rgba(cr, colors.body)
local desc = w.description or ''
desc = desc:sub(1, 1):upper() .. desc:sub(2)
card.text(cr, inner.x, y + 84, desc)
-- Rule.
local ry = y + 100
card.rgba(cr, colors.rule, 0.8)
cairo_set_line_width(cr, 1)
cairo_move_to(cr, inner.x, ry)
cairo_line_to(cr, inner.x + inner.w, ry)
cairo_stroke(cr)
-- Stats: label left, value right.
local kmh = weather.kmh(w.wind_speed)
local wind_txt = kmh
and string.format('%s %d km/h', weather.arrow(w.wind_deg), math.floor(kmh + 0.5))
or '--'
local rows = {
{ 'FEELS LIKE', w.feels_like and string.format('%d\u{00B0}', math.floor(w.feels_like + 0.5)) or '--' },
{ 'HUMIDITY', w.humidity and (w.humidity .. '%') or '--' },
{ 'WIND', wind_txt },
}
local sy = ry + 22
for _, row in ipairs(rows) do
card.font(cr, card.FONT_MONO, 11, false)
card.rgba(cr, colors.label)
card.text(cr, inner.x, sy, row[1])
card.rgba(cr, colors.value)
card.text_right(cr, inner.x + inner.w, sy, row[2])
sy = sy + 20
end
-- Rule.
local ry2 = sy + 2
card.rgba(cr, colors.rule, 0.8)
cairo_move_to(cr, inner.x, ry2)
cairo_line_to(cr, inner.x + inner.w, ry2)
cairo_stroke(cr)
-- The arc fills whatever height is left.
draw_arc(cr, inner.x, ry2 + 10, inner.w, (inner.y + inner.h) - (ry2 + 10),
colors, w, now)
end
return M
```
- [ ] **Step 2: Add the widget to the layout**
In `dashboard.lua`, replace the `layout` table:
```lua
local layout = {
{ widget = 'clock', col = 1, row = 1, w = 1, h = 2 },
{ widget = 'weather', col = 2, row = 1, w = 1, h = 2 },
}
```
- [ ] **Step 3: Schedule the fetch**
In `conky.conf.in`, replace the final two lines:
```lua
-- Cairo output covers conky.text, so nothing here is visible. The execi still
-- fires on schedule: verified with a probe config whose only text was an execi
-- producing no output. This is what refreshes the weather cache, and tying it
-- to conky means nothing fetches while the dashboard is down.
conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}]]
```
- [ ] **Step 4: Check the Lua parses**
Run: `luac -p widgets/weather.lua dashboard.lua lib/weather.lua && echo "syntax ok"`
Expected: `syntax ok`
A syntax error here would show as a blank dashboard with no message, so this
check is worth its two seconds.
- [ ] **Step 5: Run the test suite**
Run: `lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua`
Expected: three "all assertions passed" lines. `test_layout` asserts on the
grid, and the layout table just changed, so this is not a formality.
- [ ] **Step 6: Commit**
```bash
git add widgets/weather.lua dashboard.lua conky.conf.in
git commit -m "feat: add the weather card"
```
---
## Task 8: Install, look at it, and iterate
The card has never been drawn at this point. Everything below is about seeing it.
- [ ] **Step 1: Link `bin/` and silence the restart in UDT's install.sh**
In `../unified-desktop-theme/install.sh`, after the existing
`widgets` symlink (around line 189):
```bash
ln -sfn "$conky_repo/bin" "$HOME/.config/conky/bin"
```
And the conky restart (around line 216) gains the workspace rule it currently
lacks, so a reinstall does not pop the dashboard open:
```bash
(hyprctl dispatch 'hl.dsp.exec_cmd("[workspace special:dash silent] conky -c ~/.config/conky/conky.conf -d")' >/dev/null 2>&1 &)
```
- [ ] **Step 2: Render and restart**
```bash
cd ../unified-desktop-theme && ./install.sh
```
Expected: it reports conky among the reloaded components, and
`~/.config/conky/bin` now resolves.
- [ ] **Step 3: Confirm the dashboard did not steal the workspace**
```bash
hyprctl monitors -j | python3 -c 'import json,sys;print([(m["name"],m["specialWorkspace"]["name"]) for m in json.load(sys.stdin)])'
```
Expected: empty special workspace names. If `special:dash` is showing, Step 1's
restart change did not take.
- [ ] **Step 4: Retry the API key**
```bash
./bin/weather-fetch.sh; echo "exit: $?"
```
If it still reports `Invalid API key`, the key is not active yet. Continue to
Step 5 anyway: the no-data state is exactly what should be verified first, and
it is what the user sees today.
- [ ] **Step 5: Screenshot the dashboard**
`grim` captures screen coordinates, so it cannot shoot a window on an inactive
workspace. Switch first, and poll until the switch settles rather than sleeping
a fixed time:
```bash
hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null
for i in $(seq 20); do
s=$(hyprctl monitors -j | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["specialWorkspace"]["name"])')
[ "$s" = "special:dash" ] && break
sleep 0.2
done
grim -o DP-1 /tmp/dash.png
hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null
```
- [ ] **Step 6: Look at the PNG**
Open `/tmp/dash.png` and read it. Checking that `grim` exited 0 proves nothing
about what was drawn; a blank card and a correct card both exit 0.
With no live key, expect: the card chrome in place beside the clock, with
"no weather data" and the path to set. With a live key, expect: the glyph,
temperature and city on top, the condition line, three stat rows, and the arc
with a dot between the two times.
Compare against `idea1.png`. Likely first-pass problems, each fixed in
`widgets/weather.lua` and re-screenshotted:
- Text overflowing the card's right edge: reduce the font size or shorten the label.
- The arc cramped or overlapping the stats: adjust the `ry2 + 10` offset.
- The sun dot off the curve: the Bezier evaluation and the drawn curve have
diverged, which means the control points differ between them.
- [ ] **Step 7: Commit any adjustments**
```bash
# from the repo root
git add widgets/weather.lua
git commit -m "fix: adjust the weather card against the screenshot"
```
And in the UDT repo, which is a separate checkout:
```bash
cd ../unified-desktop-theme
git add install.sh
git commit -m "feat: link the conky bin dir, silence the conky restart"
```
---
## Task 9: Documentation
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Document the widget and its setup**
In `README.md`, add to the widgets section a description of the weather card,
and a setup step stating: copy `weather.env.example` to
`~/.config/udt/weather.env`, add an OWM key, `chmod 600` it, and expect a new
key to return 401 for up to a few hours while it activates.
Add to the "Gotchas worth knowing" section:
- `${execi}` fires even though `conky.text` renders nothing, which is what
schedules the weather fetch.
- The cache is written to a temp file and renamed, because the widget reads it
on an unrelated cadence.
- A bad OWM key returns HTTP 200 with an error body, so the status code alone
proves nothing.
- [ ] **Step 2: Verify the documented commands**
Run every command the README now claims works, and confirm the output matches
what is written. A README that documents a command nobody ran is how the last
one drifted.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: document the weather widget and its setup"
```
---
## Self-review notes
Checked against the spec: every section has a task. The spec's "out of scope"
(forecast) stays out. The `pressure` and `temp_min`/`temp_max` fields the user
declined are not parsed, though they remain in the cached response, which is
the point of caching it whole.
Two things this plan cannot settle, both flagged in place:
- The API key may still be inactive. Tasks 6 and 8 both state which outcomes
are acceptable, so neither blocks.
- The exact band offsets in `widgets/weather.lua` are a first pass. Task 8
Step 6 is where they get corrected against a screenshot, which is the only
way to judge them.
|