aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-09-17-weather-widget.md25
-rw-r--r--lib/weather.lua14
-rw-r--r--test/test_weather.lua11
3 files changed, 46 insertions, 4 deletions
diff --git a/docs/superpowers/plans/2026-09-17-weather-widget.md b/docs/superpowers/plans/2026-09-17-weather-widget.md
index c19b253..0b78b83 100644
--- a/docs/superpowers/plans/2026-09-17-weather-widget.md
+++ b/docs/superpowers/plans/2026-09-17-weather-widget.md
@@ -643,6 +643,17 @@ assert(weather.age_str(1000, 1000 + 90) == '1m', 'ninety seconds reads as 1m')
assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h')
assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours')
assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad')
+
+-- Neither takes `now` on faith: a nil there used to raise, and a raise is a
+-- blank dashboard rather than a message.
+assert(weather.is_stale(1000, nil) == true, 'a nil now must not raise')
+assert(weather.age_str(1000, nil) == '?', 'a nil now must not raise')
+
+-- A dt in the future means a clock skew. "stale -17m" reads as a broken
+-- widget, so the age floors at zero and the reading counts as fresh.
+assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m, got ' ..
+ tostring(weather.age_str(2000, 1000)))
+assert(weather.is_stale(2000, 1000) == false, 'a future dt is not stale')
```
- [ ] **Step 2: Run test to verify it fails**
@@ -658,15 +669,25 @@ Add to `lib/weather.lua`, before the final `return M`:
-- Three missed fetches at the 15-minute interval.
M.STALE_AFTER = 45 * 60
+-- `now` is guarded alongside `dt` although every caller passes os.time(),
+-- which cannot be nil: these were the only two functions in the module that
+-- did arithmetic on an unchecked argument, and the module's promise is that
+-- nothing raises. A raise here is a blank dashboard.
function M.is_stale(dt, now)
- if type(dt) ~= 'number' then return true end
+ if type(dt) ~= 'number' or type(now) ~= 'number' then return true end
return (now - dt) > M.STALE_AFTER
end
-- Short age for the marker beside the city: "12m", "3h", "2d".
+--
+-- A negative age is clamped to zero rather than printed. An observation
+-- timestamped in the future means a clock skew somewhere, and "stale -17m" on
+-- the card reads as a broken widget; treating it as fresh is both tidier and
+-- truer, since weather from the future is not old.
function M.age_str(dt, now)
- if type(dt) ~= 'number' then return '?' end
+ 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'
diff --git a/lib/weather.lua b/lib/weather.lua
index b1fda4d..8ecd273 100644
--- a/lib/weather.lua
+++ b/lib/weather.lua
@@ -189,15 +189,25 @@ end
-- Three missed fetches at the 15-minute interval.
M.STALE_AFTER = 45 * 60
+-- `now` is guarded alongside `dt` although every caller passes os.time(),
+-- which cannot be nil: these were the only two functions in the module that
+-- did arithmetic on an unchecked argument, and the module's promise is that
+-- nothing raises. A raise here is a blank dashboard.
function M.is_stale(dt, now)
- if type(dt) ~= 'number' then return true end
+ if type(dt) ~= 'number' or type(now) ~= 'number' then return true end
return (now - dt) > M.STALE_AFTER
end
-- Short age for the marker beside the city: "12m", "3h", "2d".
+--
+-- A negative age is clamped to zero rather than printed. An observation
+-- timestamped in the future means a clock skew somewhere, and "stale -17m" on
+-- the card reads as a broken widget; treating it as fresh is both tidier and
+-- truer, since weather from the future is not old.
function M.age_str(dt, now)
- if type(dt) ~= 'number' then return '?' end
+ 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'
diff --git a/test/test_weather.lua b/test/test_weather.lua
index 95908ce..cdcdc2d 100644
--- a/test/test_weather.lua
+++ b/test/test_weather.lua
@@ -222,4 +222,15 @@ assert(weather.age_str(1000, 1000 + 3600) == '1h', 'an hour reads as 1h')
assert(weather.age_str(1000, 1000 + 7200) == '2h', 'two hours')
assert(weather.age_str(1000, 1000 + 86400 * 2) == '2d', 'days, once it gets that bad')
+-- Neither takes `now` on faith: a nil there used to raise, and a raise is a
+-- blank dashboard rather than a message.
+assert(weather.is_stale(1000, nil) == true, 'a nil now must not raise')
+assert(weather.age_str(1000, nil) == '?', 'a nil now must not raise')
+
+-- A dt in the future means a clock skew. "stale -17m" reads as a broken
+-- widget, so the age floors at zero and the reading counts as fresh.
+assert(weather.age_str(2000, 1000) == '0m', 'a future dt reads as 0m, got ' ..
+ tostring(weather.age_str(2000, 1000)))
+assert(weather.is_stale(2000, 1000) == false, 'a future dt is not stale')
+
print('test_weather: all assertions passed')