aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-02-21 07:06:50 -0600
committerCraig Jennings <c@cjennings.net>2026-02-21 07:11:03 -0600
commitb74b98f177d92d50ddbede900ba41212e07c5f63 (patch)
tree459b1630dcc7d1c941f850565acdc16332831948
parentec8130cfe1a7390e9939b311c8db39907a3f7f44 (diff)
downloademacs-wttrin-b74b98f177d92d50ddbede900ba41212e07c5f63.tar.gz
emacs-wttrin-b74b98f177d92d50ddbede900ba41212e07c5f63.zip
feat: unified cache and staleness handling for mode-line and buffer
Replace TTL-based cache invalidation with proactive scheduled refresh. Both mode-line and buffer systems now follow: timer refreshes cache, display reads from cache, staleness indicated when data is old. Phase 1 - Mode-line cache formalization + staleness display: - Replace wttrin--mode-line-tooltip-data with wttrin--mode-line-cache as (timestamp . data) cons cell matching buffer cache pattern - Add wttrin--format-age helper for human-readable age strings - Rewrite wttrin--mode-line-update-display to take no arguments, read from cache, compute staleness (age > 2x refresh interval), dim emoji gray when stale, show staleness info in tooltip - Rewrite wttrin--mode-line-fetch-weather to write cache on success, show stale display on failure with cache, error placeholder without - Add wttrin--mode-line-update-placeholder-error for first-launch failure Phase 2 - Remove TTL, add proactive buffer refresh: - Rename wttrin-cache-ttl to wttrin-refresh-interval (default 3600s) with define-obsolete-variable-alias for backward compatibility - Change wttrin-mode-line-refresh-interval default from 900 to 3600 - Remove TTL check from wttrin--get-cached-or-fetch; serve cached data regardless of age, background timer keeps it fresh - Add buffer refresh timer (wttrin--buffer-cache-refresh) Phase 3 - Buffer staleness display: - Add wttrin--format-staleness-header for buffer age display - Insert staleness line in wttrin--display-weather before instructions Phase 4 - Cleanup: - Remove all references to wttrin--mode-line-tooltip-data - Update README.org cache settings and mode-line documentation - Update tests for new API (198 tests across 21 files, all passing)
-rw-r--r--README.org14
-rw-r--r--tests/test-integration-debug.el8
-rw-r--r--tests/test-wttrin--format-staleness-header.el80
-rw-r--r--tests/test-wttrin--get-cached-or-fetch.el109
-rw-r--r--tests/test-wttrin--mode-line-map.el11
-rw-r--r--tests/test-wttrin--mode-line-update-display.el275
-rw-r--r--tests/test-wttrin-smoke.el2
-rw-r--r--tests/testutil-wttrin.el15
-rw-r--r--wttrin.el212
9 files changed, 479 insertions, 247 deletions
diff --git a/README.org b/README.org
index d7ebd72..9f1180a 100644
--- a/README.org
+++ b/README.org
@@ -171,15 +171,15 @@ Wttrin's default is to select the unit system appropriate for the location you q
#+end_src
*** Cache Settings
-Wttrin caches weather data to reduce API calls and improve responsiveness. The cache holds data for 15 minutes by default, with a maximum of 50 entries. If you're checking weather frequently or want longer cache times, you can adjust these:
+Wttrin caches weather data and proactively refreshes it in the background. By default, the cache refreshes every hour, with a maximum of 50 entries. You can adjust these:
#+begin_src emacs-lisp
- (setq wttrin-cache-ttl (* 30 60)) ;; Cache for 30 minutes (in seconds)
- (setq wttrin-cache-max-entries 100) ;; Store up to 100 cached locations
+ (setq wttrin-refresh-interval (* 30 60)) ;; Refresh every 30 minutes (in seconds)
+ (setq wttrin-cache-max-entries 100) ;; Store up to 100 cached locations
#+end_src
*** Mode-line Weather Display
-Wttrin can display current weather for your favorite location directly in the mode-line. The weather updates automatically in the background every 15 minutes, showing a color emoji weather icon with full details on hover.
+Wttrin can display current weather for your favorite location directly in the mode-line. The weather updates automatically in the background every hour, showing a color emoji weather icon with full details on hover.
**** Basic Setup
To enable the mode-line weather display, set your favorite location and enable auto-start:
@@ -204,7 +204,7 @@ Alternatively, you can manually toggle the mode-line display:
- *Tooltip*: Hover for full details (location, temperature, conditions)
- *Left-click*: Open full weather buffer for your favorite location
- *Right-click*: Force refresh the weather data immediately
-- *Auto-refresh*: Updates every 15 minutes automatically
+- *Auto-refresh*: Updates every hour automatically
**** Customization
You can customize several aspects of the mode-line weather display:
@@ -216,8 +216,8 @@ You can customize several aspects of the mode-line weather display:
;; Auto-enable mode-line weather on startup
(setq wttrin-mode-line-auto-enable t)
- ;; Adjust refresh interval (in seconds, default is 900 = 15 minutes)
- (setq wttrin-mode-line-refresh-interval (* 10 60)) ;; Refresh every 10 minutes
+ ;; Adjust refresh interval (in seconds, default is 3600 = 1 hour)
+ (setq wttrin-mode-line-refresh-interval (* 30 60)) ;; Refresh every 30 minutes
;; Choose emoji font for color display (common options)
(setq wttrin-mode-line-emoji-font "Apple Color Emoji") ;; macOS
diff --git a/tests/test-integration-debug.el b/tests/test-integration-debug.el
index db2706b..d4abd9b 100644
--- a/tests/test-integration-debug.el
+++ b/tests/test-integration-debug.el
@@ -45,7 +45,7 @@
(wttrin-clear-cache)
(wttrin-debug-clear-log)
(setq wttrin-mode-line-string nil)
- (setq wttrin--mode-line-tooltip-data nil))
+ (setq wttrin--mode-line-cache nil))
;;; Mock URL Fetching
@@ -104,9 +104,9 @@ and emoji-extraction events to the debug log."
(should (stringp wttrin-mode-line-string))
(should (string-match-p "☀" wttrin-mode-line-string)) ; Should contain emoji
- ;; Verify tooltip data was set
- (should wttrin--mode-line-tooltip-data)
- (should (string= test-wttrin-sample-weather-data wttrin--mode-line-tooltip-data))
+ ;; Verify cache was populated with weather data
+ (should wttrin--mode-line-cache)
+ (should (string= test-wttrin-sample-weather-data (cdr wttrin--mode-line-cache)))
;; Verify debug log captured key events
(let ((log-messages (mapcar #'cdr wttrin--debug-log)))
diff --git a/tests/test-wttrin--format-staleness-header.el b/tests/test-wttrin--format-staleness-header.el
new file mode 100644
index 0000000..ef8882f
--- /dev/null
+++ b/tests/test-wttrin--format-staleness-header.el
@@ -0,0 +1,80 @@
+;;; test-wttrin--format-staleness-header.el --- Tests for staleness header -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025 Craig Jennings
+
+;;; Commentary:
+
+;; Unit tests for wttrin--format-staleness-header function.
+;; Tests that buffer staleness information is formatted correctly.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Setup and Teardown
+
+(defun test-wttrin--format-staleness-header-setup ()
+ "Setup for staleness header tests."
+ (testutil-wttrin-setup))
+
+(defun test-wttrin--format-staleness-header-teardown ()
+ "Teardown for staleness header tests."
+ (testutil-wttrin-teardown))
+
+;;; Normal Cases
+
+(ert-deftest test-wttrin--format-staleness-header-normal-returns-formatted-string ()
+ "Staleness header includes time and age when cache exists."
+ (test-wttrin--format-staleness-header-setup)
+ (unwind-protect
+ (let ((now 1000000.0))
+ (cl-letf (((symbol-function 'float-time) (lambda () now)))
+ ;; Add data cached 5 minutes ago
+ (testutil-wttrin-add-to-cache "Paris" "weather data" 300)
+ (let ((header (wttrin--format-staleness-header "Paris")))
+ (should header)
+ (should (string-match-p "Last updated:" header))
+ (should (string-match-p "5 minutes ago" header)))))
+ (test-wttrin--format-staleness-header-teardown)))
+
+(ert-deftest test-wttrin--format-staleness-header-normal-just-now ()
+ "Staleness header shows 'just now' for very recent data."
+ (test-wttrin--format-staleness-header-setup)
+ (unwind-protect
+ (let ((now 1000000.0))
+ (cl-letf (((symbol-function 'float-time) (lambda () now)))
+ (testutil-wttrin-add-to-cache "Paris" "weather data" 10)
+ (let ((header (wttrin--format-staleness-header "Paris")))
+ (should (string-match-p "just now" header)))))
+ (test-wttrin--format-staleness-header-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-wttrin--format-staleness-header-boundary-no-cache-returns-nil ()
+ "Returns nil when no cache entry exists for location."
+ (test-wttrin--format-staleness-header-setup)
+ (unwind-protect
+ (should-not (wttrin--format-staleness-header "Unknown City"))
+ (test-wttrin--format-staleness-header-teardown)))
+
+(ert-deftest test-wttrin--format-staleness-header-boundary-different-unit-system ()
+ "Cache lookup respects current unit system setting."
+ (test-wttrin--format-staleness-header-setup)
+ (unwind-protect
+ (let ((now 1000000.0))
+ (cl-letf (((symbol-function 'float-time) (lambda () now)))
+ ;; Add data under metric unit system
+ (let ((wttrin-unit-system "m"))
+ (testutil-wttrin-add-to-cache "Paris" "metric data" 300))
+ ;; Query under imperial unit system — should find nothing
+ (let ((wttrin-unit-system "u"))
+ (should-not (wttrin--format-staleness-header "Paris")))
+ ;; Query under metric — should find it
+ (let ((wttrin-unit-system "m"))
+ (should (wttrin--format-staleness-header "Paris")))))
+ (test-wttrin--format-staleness-header-teardown)))
+
+(provide 'test-wttrin--format-staleness-header)
+;;; test-wttrin--format-staleness-header.el ends here
diff --git a/tests/test-wttrin--get-cached-or-fetch.el b/tests/test-wttrin--get-cached-or-fetch.el
index 77a2689..e3383c6 100644
--- a/tests/test-wttrin--get-cached-or-fetch.el
+++ b/tests/test-wttrin--get-cached-or-fetch.el
@@ -5,7 +5,9 @@
;;; Commentary:
;; Unit tests for wttrin--get-cached-or-fetch function.
-;; Tests the core cache workflow: cache hits, misses, expiration, and error fallback.
+;; Tests the core cache workflow: cache hits, misses, and error fallback.
+;; TTL-based expiration has been removed — cached data is served regardless
+;; of age, with proactive refresh keeping data fresh in the background.
;;; Code:
@@ -36,7 +38,7 @@
;;; Normal Cases
(ert-deftest test-wttrin--get-cached-or-fetch-normal-cache-hit-returns-cached-data ()
- "Test that fresh cached data is returned without fetching."
+ "Test that cached data is returned without fetching."
(test-wttrin--get-cached-or-fetch-setup)
(unwind-protect
(let* ((location "Paris")
@@ -44,11 +46,11 @@
(now 1000.0)
(callback-result nil)
(fetch-called nil))
- ;; Pre-populate cache with fresh data
+ ;; Pre-populate cache with data
(puthash cache-key (cons now test-wttrin--get-cached-or-fetch-sample-weather)
wttrin--cache)
- ;; Mock time to be 100 seconds later (well within TTL of 900)
+ ;; Mock time to be 100 seconds later
(cl-letf (((symbol-function 'float-time)
(lambda () (+ now 100.0)))
((symbol-function 'wttrin-fetch-raw-string)
@@ -99,14 +101,15 @@
(should (equal (cdr cached) test-wttrin--get-cached-or-fetch-new-weather)))))
(test-wttrin--get-cached-or-fetch-teardown)))
-(ert-deftest test-wttrin--get-cached-or-fetch-normal-expired-cache-fetches-new-data ()
- "Test that expired cache triggers fetch and updates cache."
+(ert-deftest test-wttrin--get-cached-or-fetch-normal-old-data-still-served ()
+ "Test that old cached data is served without fetching (no TTL expiration).
+Proactive refresh keeps data fresh; on-demand reads always use cache."
(test-wttrin--get-cached-or-fetch-setup)
(unwind-protect
(let* ((location "Tokyo")
(cache-key (wttrin--make-cache-key location))
(old-time 1000.0)
- (new-time (+ old-time 1000.0)) ; 1000 seconds later (> 900 TTL)
+ (new-time (+ old-time 10000.0)) ; Very old data
(callback-result nil)
(fetch-called nil))
@@ -117,81 +120,6 @@
(cl-letf (((symbol-function 'float-time)
(lambda () new-time))
((symbol-function 'wttrin-fetch-raw-string)
- (lambda (_location callback)
- (setq fetch-called t)
- (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
- ((symbol-function 'wttrin--cleanup-cache-if-needed)
- (lambda () nil)))
-
- (wttrin--get-cached-or-fetch
- location
- (lambda (data) (setq callback-result data)))
-
- ;; Should call fetch due to expiration
- (should fetch-called)
- ;; Should return new data
- (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))
- ;; Should update cache timestamp
- (let ((cached (gethash cache-key wttrin--cache)))
- (should (equal (car cached) new-time))
- (should (equal (cdr cached) test-wttrin--get-cached-or-fetch-new-weather)))))
- (test-wttrin--get-cached-or-fetch-teardown)))
-
-;;; Boundary Cases
-
-(ert-deftest test-wttrin--get-cached-or-fetch-boundary-exactly-at-ttl-fetches ()
- "Test that cache exactly at TTL boundary triggers fetch."
- (test-wttrin--get-cached-or-fetch-setup)
- (unwind-protect
- (let* ((location "Berlin")
- (cache-key (wttrin--make-cache-key location))
- (old-time 1000.0)
- ;; Exactly at TTL boundary (900 seconds = wttrin-cache-ttl)
- (new-time (+ old-time wttrin-cache-ttl))
- (callback-result nil)
- (fetch-called nil))
-
- ;; Pre-populate cache
- (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
- wttrin--cache)
-
- (cl-letf (((symbol-function 'float-time)
- (lambda () new-time))
- ((symbol-function 'wttrin-fetch-raw-string)
- (lambda (_location callback)
- (setq fetch-called t)
- (funcall callback test-wttrin--get-cached-or-fetch-new-weather)))
- ((symbol-function 'wttrin--cleanup-cache-if-needed)
- (lambda () nil)))
-
- (wttrin--get-cached-or-fetch
- location
- (lambda (data) (setq callback-result data)))
-
- ;; At exactly TTL, should fetch (not <)
- (should fetch-called)
- (should (equal callback-result test-wttrin--get-cached-or-fetch-new-weather))))
- (test-wttrin--get-cached-or-fetch-teardown)))
-
-(ert-deftest test-wttrin--get-cached-or-fetch-boundary-one-second-before-ttl-uses-cache ()
- "Test that cache one second before TTL uses cached data."
- (test-wttrin--get-cached-or-fetch-setup)
- (unwind-protect
- (let* ((location "Madrid")
- (cache-key (wttrin--make-cache-key location))
- (old-time 1000.0)
- ;; One second before TTL expiration
- (new-time (+ old-time (- wttrin-cache-ttl 1)))
- (callback-result nil)
- (fetch-called nil))
-
- ;; Pre-populate cache
- (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
- wttrin--cache)
-
- (cl-letf (((symbol-function 'float-time)
- (lambda () new-time))
- ((symbol-function 'wttrin-fetch-raw-string)
(lambda (_location _callback)
(setq fetch-called t))))
@@ -199,11 +127,13 @@
location
(lambda (data) (setq callback-result data)))
- ;; Should use cache (still fresh)
+ ;; Should serve old data without fetching
(should-not fetch-called)
(should (equal callback-result test-wttrin--get-cached-or-fetch-sample-weather))))
(test-wttrin--get-cached-or-fetch-teardown)))
+;;; Boundary Cases
+
(ert-deftest test-wttrin--get-cached-or-fetch-boundary-force-refresh-bypasses-fresh-cache ()
"Test that force refresh flag bypasses fresh cache."
(test-wttrin--get-cached-or-fetch-setup)
@@ -220,7 +150,7 @@
wttrin--cache)
(cl-letf (((symbol-function 'float-time)
- (lambda () (+ now 100.0))) ; Well within TTL
+ (lambda () (+ now 100.0)))
((symbol-function 'wttrin-fetch-raw-string)
(lambda (_location callback)
(setq fetch-called t)
@@ -314,17 +244,16 @@
(unwind-protect
(let* ((location "Vienna")
(cache-key (wttrin--make-cache-key location))
- (old-time 1000.0)
- (new-time (+ old-time 2000.0)) ; Well expired
(callback-result nil)
- (message-shown nil))
+ (message-shown nil)
+ (wttrin--force-refresh t)) ; Force refresh to trigger fetch
- ;; Pre-populate cache with expired data
- (puthash cache-key (cons old-time test-wttrin--get-cached-or-fetch-sample-weather)
+ ;; Pre-populate cache with data
+ (puthash cache-key (cons 1000.0 test-wttrin--get-cached-or-fetch-sample-weather)
wttrin--cache)
(cl-letf (((symbol-function 'float-time)
- (lambda () new-time))
+ (lambda () 3000.0))
((symbol-function 'wttrin-fetch-raw-string)
(lambda (_location callback)
;; Simulate network failure
diff --git a/tests/test-wttrin--mode-line-map.el b/tests/test-wttrin--mode-line-map.el
index 2328300..ef8f0d2 100644
--- a/tests/test-wttrin--mode-line-map.el
+++ b/tests/test-wttrin--mode-line-map.el
@@ -75,16 +75,17 @@
"Test that mode-line display uses wttrin--mode-line-map after refactoring.
This test verifies the refactoring eliminated inline keymap construction."
;; Set up minimal mode-line state
- (let ((wttrin-favorite-location "Test, CA")
- (wttrin--mode-line-tooltip-data "Test weather"))
- ;; Update the mode-line display
- (wttrin--mode-line-update-display "☀️")
+ (let ((wttrin-favorite-location "Test, CA"))
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Test, CA: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display))
;; Extract the keymap property from wttrin-mode-line-string
(let ((keymap-prop (get-text-property 0 'local-map wttrin-mode-line-string)))
;; After refactoring, should use the shared wttrin--mode-line-map
;; Not a freshly constructed keymap on each call
- (should (eq keymap-prop wttrin--mode-line-map)))))
+ (should (eq keymap-prop wttrin--mode-line-map)))
+ (setq wttrin--mode-line-cache nil)))
(provide 'test-wttrin--mode-line-map)
;;; test-wttrin--mode-line-map.el ends here
diff --git a/tests/test-wttrin--mode-line-update-display.el b/tests/test-wttrin--mode-line-update-display.el
index 07ab73f..57a5823 100644
--- a/tests/test-wttrin--mode-line-update-display.el
+++ b/tests/test-wttrin--mode-line-update-display.el
@@ -3,8 +3,9 @@
;; Copyright (C) 2025 Craig Jennings
;;; Commentary:
-;; Unit tests for wttrin--mode-line-update-display and
-;; wttrin--mode-line-valid-response-p.
+;; Unit tests for wttrin--mode-line-update-display,
+;; wttrin--mode-line-valid-response-p, wttrin--mode-line-fetch-weather,
+;; wttrin--mode-line-set-placeholder, and wttrin--mode-line-update-placeholder-error.
;;; Code:
@@ -18,13 +19,13 @@
"Setup for mode-line update display tests."
(testutil-wttrin-setup)
(setq wttrin-mode-line-string nil)
- (setq wttrin--mode-line-tooltip-data nil))
+ (setq wttrin--mode-line-cache nil))
(defun test-wttrin--mode-line-update-display-teardown ()
"Teardown for mode-line update display tests."
(testutil-wttrin-teardown)
(setq wttrin-mode-line-string nil)
- (setq wttrin--mode-line-tooltip-data nil))
+ (setq wttrin--mode-line-cache nil))
;;; --------------------------------------------------------------------------
;;; wttrin--mode-line-valid-response-p
@@ -65,45 +66,65 @@
(should-not (wttrin--mode-line-valid-response-p 42)))
;;; --------------------------------------------------------------------------
+;;; wttrin--format-age
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin--format-age-just-now ()
+ "Seconds under 60 returns just now."
+ (should (equal (wttrin--format-age 0) "just now"))
+ (should (equal (wttrin--format-age 59) "just now")))
+
+(ert-deftest test-wttrin--format-age-minutes ()
+ "Seconds in the minutes range."
+ (should (equal (wttrin--format-age 60) "1 minute ago"))
+ (should (equal (wttrin--format-age 300) "5 minutes ago"))
+ (should (equal (wttrin--format-age 3599) "59 minutes ago")))
+
+(ert-deftest test-wttrin--format-age-hours ()
+ "Seconds in the hours range."
+ (should (equal (wttrin--format-age 3600) "1 hour ago"))
+ (should (equal (wttrin--format-age 7200) "2 hours ago"))
+ (should (equal (wttrin--format-age 86399) "23 hours ago")))
+
+(ert-deftest test-wttrin--format-age-days ()
+ "Seconds in the days range."
+ (should (equal (wttrin--format-age 86400) "1 day ago"))
+ (should (equal (wttrin--format-age 172800) "2 days ago")))
+
+;;; --------------------------------------------------------------------------
;;; wttrin--mode-line-update-display
;;; --------------------------------------------------------------------------
;;; Normal Cases
(ert-deftest test-wttrin--mode-line-update-display-normal-sets-mode-line-string ()
- "Display update sets wttrin-mode-line-string to non-nil."
+ "Display update from cache sets wttrin-mode-line-string to non-nil."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (progn
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display)
(should wttrin-mode-line-string))
(test-wttrin--mode-line-update-display-teardown)))
-(ert-deftest test-wttrin--mode-line-update-display-normal-stores-tooltip-data ()
- "Display update stores weather string as tooltip data."
- (test-wttrin--mode-line-update-display-setup)
- (unwind-protect
- (progn
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
- (should (equal wttrin--mode-line-tooltip-data "Paris: ☀️ +61°F Clear")))
- (test-wttrin--mode-line-update-display-teardown)))
-
(ert-deftest test-wttrin--mode-line-update-display-normal-extracts-emoji ()
"Display update extracts emoji character into mode-line string."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
(let ((wttrin-mode-line-emoji-font nil))
- (wttrin--mode-line-update-display "Paris: X +61°F Clear")
- ;; Mode-line string should contain the extracted character
- (should (string-match-p "X" (substring-no-properties wttrin-mode-line-string))))
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: X +61°F Clear"))
+ (wttrin--mode-line-update-display)
+ (should (string-match-p "X" (substring-no-properties wttrin-mode-line-string)))))
(test-wttrin--mode-line-update-display-teardown)))
(ert-deftest test-wttrin--mode-line-update-display-normal-has-help-echo ()
"Display update sets help-echo property for tooltip."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (progn
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display)
(should (get-text-property 0 'help-echo wttrin-mode-line-string)))
(test-wttrin--mode-line-update-display-teardown)))
@@ -111,91 +132,124 @@
"Display update sets local-map property for mouse interaction."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (progn
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display)
(should (eq (get-text-property 0 'local-map wttrin-mode-line-string)
wttrin--mode-line-map)))
(test-wttrin--mode-line-update-display-teardown)))
-;;; Boundary Cases
-
-(ert-deftest test-wttrin--mode-line-update-display-boundary-no-emoji-match-uses-fallback ()
- "When emoji regex doesn't match, fallback character '?' is used."
+(ert-deftest test-wttrin--mode-line-update-display-normal-fresh-tooltip-shows-updated ()
+ "Fresh data tooltip shows weather data and 'Updated' age."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (let ((wttrin-mode-line-emoji-font nil))
- (wttrin--mode-line-update-display "no colon here")
- (should (string-match-p "\\?" (substring-no-properties wttrin-mode-line-string))))
+ (cl-letf (((symbol-function 'float-time) (lambda () 1300.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display)
+ (let ((tooltip (get-text-property 0 'help-echo wttrin-mode-line-string)))
+ (should (string-match-p "Paris" tooltip))
+ (should (string-match-p "Updated 5 minutes ago" tooltip))))
(test-wttrin--mode-line-update-display-teardown)))
-;;; Tooltip Lambda Tests
+;;; Stale Cases
-(ert-deftest test-wttrin--mode-line-update-display-normal-tooltip-returns-weather-data ()
- "Tooltip lambda returns weather data when available."
+(ert-deftest test-wttrin--mode-line-update-display-stale-tooltip-shows-stale ()
+ "Stale data tooltip indicates staleness and retry info."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (progn
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
- (let ((tooltip-fn (get-text-property 0 'help-echo wttrin-mode-line-string)))
- (should (equal (funcall tooltip-fn nil nil nil) "Paris: ☀️ +61°F Clear"))))
+ (let ((wttrin-mode-line-refresh-interval 900))
+ (cl-letf (((symbol-function 'float-time) (lambda () 3000.0)))
+ ;; Data is 2000 seconds old, threshold is 2*900=1800 -> stale
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display)
+ (let ((tooltip (get-text-property 0 'help-echo wttrin-mode-line-string)))
+ (should (string-match-p "Stale" tooltip))
+ (should (string-match-p "fetch failed" tooltip)))))
(test-wttrin--mode-line-update-display-teardown)))
-(ert-deftest test-wttrin--mode-line-update-display-boundary-tooltip-empty-string-uses-fallback ()
- "Tooltip lambda falls back when tooltip data is empty string."
+(ert-deftest test-wttrin--mode-line-update-display-stale-emoji-dimmed ()
+ "Stale data dims the emoji with gray foreground."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (let ((wttrin-favorite-location "Paris"))
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
- ;; Simulate empty tooltip data (as would happen with bad response)
- (setq wttrin--mode-line-tooltip-data "")
- (let ((tooltip-fn (get-text-property 0 'help-echo wttrin-mode-line-string)))
- (should (string-match-p "Weather for Paris" (funcall tooltip-fn nil nil nil)))))
+ (let ((wttrin-mode-line-refresh-interval 900)
+ (wttrin-mode-line-emoji-font nil))
+ (cl-letf (((symbol-function 'float-time) (lambda () 3000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: X +61°F Clear"))
+ (wttrin--mode-line-update-display)
+ ;; The emoji character should have a gray face
+ (let* ((str wttrin-mode-line-string)
+ ;; Find the emoji position (after the space)
+ (emoji-pos 1)
+ (face (get-text-property emoji-pos 'face str)))
+ (should face)
+ (should (equal (plist-get face :foreground) "gray60")))))
(test-wttrin--mode-line-update-display-teardown)))
-(ert-deftest test-wttrin--mode-line-update-display-boundary-tooltip-nil-uses-fallback ()
- "Tooltip lambda falls back when tooltip data is nil."
+;;; Boundary Cases
+
+(ert-deftest test-wttrin--mode-line-update-display-boundary-no-emoji-match-uses-fallback ()
+ "When emoji regex doesn't match, fallback character '?' is used."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (let ((wttrin-favorite-location "Paris"))
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
- (setq wttrin--mode-line-tooltip-data nil)
- (let ((tooltip-fn (get-text-property 0 'help-echo wttrin-mode-line-string)))
- (should (string-match-p "Weather for Paris" (funcall tooltip-fn nil nil nil)))))
+ (let ((wttrin-mode-line-emoji-font nil))
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "no colon here"))
+ (wttrin--mode-line-update-display)
+ (should (string-match-p "\\?" (substring-no-properties wttrin-mode-line-string)))))
+ (test-wttrin--mode-line-update-display-teardown)))
+
+(ert-deftest test-wttrin--mode-line-update-display-boundary-nil-cache-does-nothing ()
+ "When cache is nil, update-display does not set mode-line-string."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (progn
+ (setq wttrin--mode-line-cache nil)
+ (wttrin--mode-line-update-display)
+ (should-not wttrin-mode-line-string))
(test-wttrin--mode-line-update-display-teardown)))
;;; --------------------------------------------------------------------------
-;;; wttrin--mode-line-fetch-weather (validation integration)
+;;; wttrin--mode-line-fetch-weather
;;; --------------------------------------------------------------------------
+(ert-deftest test-wttrin--mode-line-fetch-weather-normal-valid-response-updates-cache ()
+ "Valid API response populates cache and updates display."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location "Paris"))
+ (testutil-wttrin-mock-http-response "Paris: ☀️ +61°F Clear"
+ (wttrin--mode-line-fetch-weather)
+ (should wttrin--mode-line-cache)
+ (should (equal (cdr wttrin--mode-line-cache) "Paris: ☀️ +61°F Clear"))
+ (should wttrin-mode-line-string)))
+ (test-wttrin--mode-line-update-display-teardown)))
+
(ert-deftest test-wttrin--mode-line-fetch-weather-error-empty-response-keeps-previous ()
- "Empty API response does not overwrite previous valid display."
+ "Empty API response does not overwrite previous valid cache."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
(let ((wttrin-favorite-location "Paris"))
- ;; Set up a valid prior state
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
- (let ((previous-string wttrin-mode-line-string)
- (previous-tooltip wttrin--mode-line-tooltip-data))
+ ;; Set up a valid prior cache
+ (setq wttrin--mode-line-cache (cons (float-time) "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display)
+ (let ((previous-cache wttrin--mode-line-cache))
;; Simulate fetch returning empty response
(testutil-wttrin-mock-http-response ""
(wttrin--mode-line-fetch-weather)
- ;; Previous state should be preserved
- (should (equal wttrin-mode-line-string previous-string))
- (should (equal wttrin--mode-line-tooltip-data previous-tooltip)))))
+ ;; Cache should be preserved
+ (should (equal wttrin--mode-line-cache previous-cache)))))
(test-wttrin--mode-line-update-display-teardown)))
(ert-deftest test-wttrin--mode-line-fetch-weather-error-no-colon-response-keeps-previous ()
- "Malformed API response without colon does not overwrite previous valid display."
+ "Malformed API response without colon does not overwrite previous valid cache."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
(let ((wttrin-favorite-location "Paris"))
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
- (let ((previous-string wttrin-mode-line-string)
- (previous-tooltip wttrin--mode-line-tooltip-data))
+ (setq wttrin--mode-line-cache (cons (float-time) "Paris: ☀️ +61°F Clear"))
+ (let ((previous-cache wttrin--mode-line-cache))
(testutil-wttrin-mock-http-response "Unknown location"
(wttrin--mode-line-fetch-weather)
- (should (equal wttrin-mode-line-string previous-string))
- (should (equal wttrin--mode-line-tooltip-data previous-tooltip)))))
+ (should (equal wttrin--mode-line-cache previous-cache)))))
(test-wttrin--mode-line-update-display-teardown)))
(ert-deftest test-wttrin--mode-line-fetch-weather-error-nil-location-does-not-fetch ()
@@ -210,15 +264,43 @@
(should-not fetch-called)))
(test-wttrin--mode-line-update-display-teardown)))
-(ert-deftest test-wttrin--mode-line-fetch-weather-normal-valid-response-updates-display ()
- "Valid API response updates the mode-line display."
+(ert-deftest test-wttrin--mode-line-fetch-weather-error-network-fail-with-cache-shows-stale ()
+ "Network failure with existing cache triggers stale display."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
- (let ((wttrin-favorite-location "Paris"))
- (testutil-wttrin-mock-http-response "Paris: ☀️ +61°F Clear"
+ (let ((wttrin-favorite-location "Paris")
+ (wttrin-mode-line-refresh-interval 900))
+ ;; Set up old cache data
+ (setq wttrin--mode-line-cache (cons (- (float-time) 2000) "Paris: ☀️ +61°F Clear"))
+ ;; Simulate network failure (nil data)
+ (cl-letf (((symbol-function 'wttrin--fetch-url)
+ (lambda (_url callback) (funcall callback nil))))
(wttrin--mode-line-fetch-weather)
+ ;; Cache should still exist
+ (should wttrin--mode-line-cache)
+ ;; Mode-line should be updated (stale display)
+ (should wttrin-mode-line-string)))
+ (test-wttrin--mode-line-update-display-teardown)))
+
+(ert-deftest test-wttrin--mode-line-fetch-weather-error-network-fail-no-cache-shows-error ()
+ "Network failure with no cache shows error placeholder."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location "Paris")
+ (wttrin-mode-line-emoji-font nil))
+ ;; No cache
+ (should-not wttrin--mode-line-cache)
+ ;; Simulate network failure
+ (cl-letf (((symbol-function 'wttrin--fetch-url)
+ (lambda (_url callback) (funcall callback nil))))
+ (wttrin--mode-line-fetch-weather)
+ ;; Should show error placeholder with hourglass
(should wttrin-mode-line-string)
- (should (equal wttrin--mode-line-tooltip-data "Paris: ☀️ +61°F Clear"))))
+ (should (string-match-p "⏳" (substring-no-properties wttrin-mode-line-string)))
+ ;; Tooltip should mention failure
+ (let ((tooltip (get-text-property 0 'help-echo wttrin-mode-line-string)))
+ (should (string-match-p "failed" tooltip))
+ (should (string-match-p "Paris" tooltip)))))
(test-wttrin--mode-line-update-display-teardown)))
;;; --------------------------------------------------------------------------
@@ -268,26 +350,65 @@
(test-wttrin--mode-line-update-display-teardown)))
(ert-deftest test-wttrin--mode-line-set-placeholder-normal-replaced-by-real-data ()
- "Placeholder is replaced when real weather data arrives."
+ "Placeholder is replaced when real weather data arrives via cache."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
(let ((wttrin-favorite-location "Paris")
(wttrin-mode-line-emoji-font nil))
(wttrin--mode-line-set-placeholder)
(should (string-match-p "⏳" (substring-no-properties wttrin-mode-line-string)))
- (wttrin--mode-line-update-display "Paris: ☀️ +61°F Clear")
+ (cl-letf (((symbol-function 'float-time) (lambda () 1000.0)))
+ (setq wttrin--mode-line-cache (cons 1000.0 "Paris: ☀️ +61°F Clear"))
+ (wttrin--mode-line-update-display))
(should-not (string-match-p "⏳" (substring-no-properties wttrin-mode-line-string))))
(test-wttrin--mode-line-update-display-teardown)))
;;; Boundary Cases
-(ert-deftest test-wttrin--mode-line-set-placeholder-boundary-does-not-set-tooltip-data ()
- "Placeholder does not contaminate tooltip data variable."
+(ert-deftest test-wttrin--mode-line-set-placeholder-boundary-does-not-set-cache ()
+ "Placeholder does not contaminate cache variable."
(test-wttrin--mode-line-update-display-setup)
(unwind-protect
(let ((wttrin-favorite-location "Paris"))
(wttrin--mode-line-set-placeholder)
- (should-not wttrin--mode-line-tooltip-data))
+ (should-not wttrin--mode-line-cache))
+ (test-wttrin--mode-line-update-display-teardown)))
+
+;;; --------------------------------------------------------------------------
+;;; wttrin--mode-line-update-placeholder-error
+;;; --------------------------------------------------------------------------
+
+(ert-deftest test-wttrin--mode-line-update-placeholder-error-sets-mode-line-string ()
+ "Error placeholder sets wttrin-mode-line-string to non-nil."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location "Paris"))
+ (wttrin--mode-line-update-placeholder-error)
+ (should wttrin-mode-line-string))
+ (test-wttrin--mode-line-update-display-teardown)))
+
+(ert-deftest test-wttrin--mode-line-update-placeholder-error-contains-hourglass ()
+ "Error placeholder displays hourglass emoji."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location "Paris")
+ (wttrin-mode-line-emoji-font nil))
+ (wttrin--mode-line-update-placeholder-error)
+ (should (string-match-p "⏳" (substring-no-properties wttrin-mode-line-string))))
+ (test-wttrin--mode-line-update-display-teardown)))
+
+(ert-deftest test-wttrin--mode-line-update-placeholder-error-tooltip-mentions-failure ()
+ "Error placeholder tooltip mentions failure and retry."
+ (test-wttrin--mode-line-update-display-setup)
+ (unwind-protect
+ (let ((wttrin-favorite-location "Paris")
+ (wttrin-mode-line-refresh-interval 900))
+ (wttrin--mode-line-update-placeholder-error)
+ (let ((tooltip (get-text-property 0 'help-echo wttrin-mode-line-string)))
+ (should (string-match-p "failed" tooltip))
+ (should (string-match-p "Paris" tooltip))
+ (should (string-match-p "retry" tooltip))
+ (should (string-match-p "15 minutes" tooltip))))
(test-wttrin--mode-line-update-display-teardown)))
(provide 'test-wttrin--mode-line-update-display)
diff --git a/tests/test-wttrin-smoke.el b/tests/test-wttrin-smoke.el
index 2a2d828..1fa0539 100644
--- a/tests/test-wttrin-smoke.el
+++ b/tests/test-wttrin-smoke.el
@@ -80,7 +80,7 @@ This is a REQUIRED dependency - wttrin cannot function without it."
"Test that key defcustom variables are defined."
(should (boundp 'wttrin-default-locations))
(should (boundp 'wttrin-unit-system))
- (should (boundp 'wttrin-cache-ttl))
+ (should (boundp 'wttrin-refresh-interval))
(should (boundp 'wttrin-cache-max-entries))
(should (boundp 'wttrin-favorite-location))
(should (boundp 'wttrin-mode-line-refresh-interval))
diff --git a/tests/testutil-wttrin.el b/tests/testutil-wttrin.el
index 685cc09..42c8c21 100644
--- a/tests/testutil-wttrin.el
+++ b/tests/testutil-wttrin.el
@@ -72,10 +72,10 @@
`(let ((wttrin-unit-system ,unit-system))
,@body))
-(defmacro testutil-wttrin-with-cache-ttl (ttl &rest body)
- "Execute BODY with wttrin-cache-ttl temporarily set to TTL."
+(defmacro testutil-wttrin-with-refresh-interval (interval &rest body)
+ "Execute BODY with wttrin-refresh-interval temporarily set to INTERVAL."
(declare (indent 1))
- `(let ((wttrin-cache-ttl ,ttl))
+ `(let ((wttrin-refresh-interval ,interval))
,@body))
(defmacro testutil-wttrin-with-cache-max (max-entries &rest body)
@@ -110,6 +110,15 @@
(funcall callback nil)))))
,@body))
+;;; Mode-line Cache Helpers
+
+(defun testutil-wttrin-set-mode-line-cache (data &optional age-seconds)
+ "Set mode-line cache to DATA, optionally aged by AGE-SECONDS."
+ (let ((timestamp (if age-seconds
+ (- (float-time) age-seconds)
+ (float-time))))
+ (setq wttrin--mode-line-cache (cons timestamp data))))
+
;;; Test Setup and Teardown
(defun testutil-wttrin-setup ()
diff --git a/wttrin.el b/wttrin.el
index 5af7f44..47f4af7 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -88,11 +88,16 @@ units (default)."
:type 'string)
-(defcustom wttrin-cache-ttl 900 ; 15 minutes
- "Time to live for cached weather data in seconds."
+(defcustom wttrin-refresh-interval 3600 ; 1 hour
+ "Interval in seconds between proactive weather data refreshes.
+Controls how often the background timer refreshes cached weather data
+for `wttrin-favorite-location'. Data older than 2x this interval
+is considered stale."
:group 'wttrin
:type 'integer)
+(define-obsolete-variable-alias 'wttrin-cache-ttl 'wttrin-refresh-interval "0.3.0")
+
(defcustom wttrin-cache-max-entries 50
"Maximum number of entries to keep in cache."
:group 'wttrin
@@ -114,9 +119,9 @@ The weather icon and tooltip will update automatically in the background."
:type '(choice (const :tag "Disabled" nil)
(string :tag "Location")))
-(defcustom wttrin-mode-line-refresh-interval 900
+(defcustom wttrin-mode-line-refresh-interval 3600
"Interval in seconds to refresh mode-line weather data.
-Default is 900 seconds (15 minutes)."
+Default is 3600 seconds (1 hour)."
:group 'wttrin
:type 'integer)
@@ -186,8 +191,10 @@ Set this to t BEFORE loading wttrin, typically in your init file:
(defvar wttrin--mode-line-timer nil
"Timer object for mode-line weather refresh.")
-(defvar wttrin--mode-line-tooltip-data nil
- "Cached full weather data for tooltip display.")
+(defvar wttrin--mode-line-cache nil
+ "Cached mode-line weather data as (timestamp . data) cons cell.
+When non-nil, car is the `float-time' when data was fetched,
+and cdr is the weather string from the API.")
(defvar wttrin--mode-line-map
(let ((map (make-sparse-keymap)))
@@ -198,6 +205,21 @@ Set this to t BEFORE loading wttrin, typically in your init file:
Left-click: refresh weather and open buffer.
Right-click: force-refresh cache and update tooltip.")
+(defun wttrin--format-age (seconds)
+ "Format SECONDS as a human-readable age string.
+Returns \"just now\" for <60s, \"X minutes ago\", \"X hours ago\", or \"X days ago\"."
+ (cond
+ ((< seconds 60) "just now")
+ ((< seconds 3600)
+ (let ((minutes (floor (/ seconds 60))))
+ (format "%d %s ago" minutes (if (= minutes 1) "minute" "minutes"))))
+ ((< seconds 86400)
+ (let ((hours (floor (/ seconds 3600))))
+ (format "%d %s ago" hours (if (= hours 1) "hour" "hours"))))
+ (t
+ (let ((days (floor (/ seconds 86400))))
+ (format "%d %s ago" days (if (= days 1) "day" "days"))))))
+
(defun wttrin-additional-url-params ()
"Concatenates extra information into the URL."
(if wttrin-unit-system
@@ -353,6 +375,19 @@ Returns processed string ready for display."
(goto-char (point-max))
(insert "\n\nPress: [a] for another location [g] to refresh [q] to quit"))
+(defun wttrin--format-staleness-header (location)
+ "Return a staleness header string for LOCATION, or nil if no cache entry.
+Looks up the cache timestamp for LOCATION and formats a line like
+\"Last updated: 2:30 PM (5 minutes ago)\"."
+ (let* ((cache-key (wttrin--make-cache-key location))
+ (cached (gethash cache-key wttrin--cache)))
+ (when cached
+ (let* ((timestamp (car cached))
+ (age (- (float-time) timestamp))
+ (time-str (format-time-string "%l:%M %p" (seconds-to-time timestamp)))
+ (age-str (wttrin--format-age age)))
+ (format "Last updated: %s (%s)" (string-trim time-str) age-str)))))
+
(defun wttrin--display-weather (location-name raw-string)
"Display weather data RAW-STRING for LOCATION-NAME in weather buffer."
;; Save debug data if enabled
@@ -375,6 +410,9 @@ Returns processed string ready for display."
(require 'xterm-color)
(setq-local xterm-color--state :char)
(insert (wttrin--process-weather-content raw-string))
+ (let ((staleness (wttrin--format-staleness-header location-name)))
+ (when staleness
+ (insert "\n" staleness)))
(wttrin--add-buffer-instructions)
;; align buffer to top
(goto-char (point-min)))
@@ -406,17 +444,16 @@ Returns processed string ready for display."
(concat location "|" (or wttrin-unit-system "default")))
(defun wttrin--get-cached-or-fetch (location callback)
- "Asynchronously get cached weather for LOCATION or fetch if expired.
+ "Get cached weather for LOCATION or fetch if not cached.
+If cache has data and not force-refreshing, serves it immediately
+regardless of age. The background refresh timer keeps data fresh.
CALLBACK is called with the weather data string when ready, or nil on error."
(let* ((cache-key (wttrin--make-cache-key location))
(cached (gethash cache-key wttrin--cache))
- (timestamp (car cached))
(data (cdr cached))
(now (float-time)))
- (if (and cached
- (< (- now timestamp) wttrin-cache-ttl)
- (not wttrin--force-refresh))
- ;; Return cached data immediately
+ (if (and cached (not wttrin--force-refresh))
+ ;; Return cached data immediately regardless of age
(funcall callback data)
;; Fetch fresh data asynchronously
(wttrin-fetch-raw-string
@@ -484,18 +521,36 @@ e.g., \"Paris: ☀️ +61°F Clear\"."
(not (string-empty-p weather-string))
(string-match-p ":" weather-string)))
+(defun wttrin--mode-line-update-placeholder-error ()
+ "Update placeholder to show fetch error state.
+Keeps the hourglass icon but updates tooltip to explain the failure
+and indicate when retry will occur."
+ (let* ((icon (if wttrin-mode-line-emoji-font
+ (propertize "⏳"
+ 'face (list :family wttrin-mode-line-emoji-font
+ :height 1.0))
+ "⏳"))
+ (retry-minutes (ceiling (/ wttrin-mode-line-refresh-interval 60.0))))
+ (setq wttrin-mode-line-string
+ (propertize (concat " " icon)
+ 'help-echo (format "Weather fetch failed for %s — will retry in %d minutes"
+ wttrin-favorite-location retry-minutes)
+ 'mouse-face 'mode-line-highlight
+ 'local-map wttrin--mode-line-map)))
+ (force-mode-line-update t))
+
(defun wttrin--mode-line-fetch-weather ()
"Fetch weather for favorite location and update mode-line display.
-Uses wttr.in custom format for concise weather with emoji."
+Uses wttr.in custom format for concise weather with emoji.
+On success, writes to `wttrin--mode-line-cache' and updates display.
+On failure with existing cache, shows stale data.
+On failure with no cache, shows error placeholder."
(when (featurep 'wttrin-debug)
(wttrin--debug-log "mode-line-fetch: Starting fetch for %s" wttrin-favorite-location))
(if (not wttrin-favorite-location)
(when (featurep 'wttrin-debug)
(wttrin--debug-log "mode-line-fetch: No favorite location set, skipping"))
(let* ((location wttrin-favorite-location)
- ;; Custom format: location + emoji + temp + conditions
- ;; %l=location, %c=weather emoji, %t=temp, %C=conditions
- ;; Note: unit system must come BEFORE format in query string
(format-params (if wttrin-unit-system
(concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C")
"?format=%l:+%c+%t+%C"))
@@ -512,51 +567,60 @@ Uses wttr.in custom format for concise weather with emoji."
(when (featurep 'wttrin-debug)
(wttrin--debug-log "mode-line-fetch: Received data = %S" trimmed-data))
(if (wttrin--mode-line-valid-response-p trimmed-data)
- (wttrin--mode-line-update-display trimmed-data)
+ (progn
+ (setq wttrin--mode-line-cache (cons (float-time) trimmed-data))
+ (wttrin--mode-line-update-display))
(when (featurep 'wttrin-debug)
(wttrin--debug-log "mode-line-fetch: Invalid response, keeping previous display"))))
+ ;; Network error / nil data
(when (featurep 'wttrin-debug)
- (wttrin--debug-log "mode-line-fetch: No data received (network error)"))))))))
-
-(defun wttrin--mode-line-update-display (weather-string)
- "Update mode-line display with WEATHER-STRING.
-Extracts emoji for mode-line, stores full info for tooltip.
-WEATHER-STRING format: \"Location: emoji temp conditions\",
-e.g., \"Paris: ☀️ +61°F Clear\"."
- (when (featurep 'wttrin-debug)
- (wttrin--debug-log "mode-line-display: Updating display with: %S" weather-string))
- ;; Store full weather info for tooltip
- (setq wttrin--mode-line-tooltip-data weather-string)
- ;; Extract just the emoji for mode-line display
- ;; Format is "Location: emoji +temp conditions"
- ;; We want just the emoji (first character after ": ")
- (let* ((emoji (if (string-match ":\\s-*\\(.\\)" weather-string)
- (match-string 1 weather-string)
- "?")) ; Fallback if parsing fails
- ;; Force color emoji rendering by setting font family
- (emoji-with-font (if wttrin-mode-line-emoji-font
- (propertize emoji
- 'face (list :family wttrin-mode-line-emoji-font
- :height 1.0))
- emoji)))
- (when (featurep 'wttrin-debug)
- (wttrin--debug-log "mode-line-display: Extracted emoji = %S, font = %s"
- emoji wttrin-mode-line-emoji-font))
- (setq wttrin-mode-line-string
- (propertize (concat " " emoji-with-font)
- 'help-echo (lambda (_window _object _pos)
- (let ((tip wttrin--mode-line-tooltip-data))
- (if (and tip (not (string-empty-p tip)))
- tip
- (format "Weather for %s\nClick to refresh"
- wttrin-favorite-location))))
- 'mouse-face 'mode-line-highlight
- 'local-map wttrin--mode-line-map)))
- (force-mode-line-update t)
- (when (featurep 'wttrin-debug)
- (wttrin--debug-log "mode-line-display: Complete. mode-line-string set = %s, tooltip = %S"
- (if wttrin-mode-line-string "YES" "NO")
- wttrin--mode-line-tooltip-data)))
+ (wttrin--debug-log "mode-line-fetch: No data received (network error)"))
+ (if wttrin--mode-line-cache
+ ;; Have stale cache — update display to show staleness
+ (wttrin--mode-line-update-display)
+ ;; No cache at all — show error placeholder
+ (wttrin--mode-line-update-placeholder-error))))))))
+
+(defun wttrin--mode-line-update-display ()
+ "Update mode-line display from `wttrin--mode-line-cache'.
+Reads cached weather data, computes age, and sets the mode-line string.
+If data is stale (age > 2x refresh interval), dims the emoji and
+shows staleness info in tooltip."
+ (when wttrin--mode-line-cache
+ (let* ((timestamp (car wttrin--mode-line-cache))
+ (weather-string (cdr wttrin--mode-line-cache))
+ (age (- (float-time) timestamp))
+ (stale-p (> age (* 2 wttrin-mode-line-refresh-interval)))
+ (age-str (wttrin--format-age age)))
+ (when (featurep 'wttrin-debug)
+ (wttrin--debug-log "mode-line-display: Updating from cache, age=%s, stale=%s"
+ age-str stale-p))
+ ;; Extract just the emoji for mode-line display
+ (let* ((emoji (if (string-match ":\\s-*\\(.\\)" weather-string)
+ (match-string 1 weather-string)
+ "?"))
+ (emoji-with-font
+ (if wttrin-mode-line-emoji-font
+ (propertize emoji
+ 'face (list :family wttrin-mode-line-emoji-font
+ :height 1.0
+ :foreground (when stale-p "gray60")))
+ (if stale-p
+ (propertize emoji 'face '(:foreground "gray60"))
+ emoji)))
+ (tooltip (if stale-p
+ (format "%s\nStale: updated %s — fetch failed, will retry"
+ weather-string age-str)
+ (format "%s\nUpdated %s" weather-string age-str))))
+ (when (featurep 'wttrin-debug)
+ (wttrin--debug-log "mode-line-display: Extracted emoji = %S, stale = %s"
+ emoji stale-p))
+ (setq wttrin-mode-line-string
+ (propertize (concat " " emoji-with-font)
+ 'help-echo tooltip
+ 'mouse-face 'mode-line-highlight
+ 'local-map wttrin--mode-line-map)))))
+ (force-mode-line-update t))
(defun wttrin-mode-line-click ()
"Handle left-click on mode-line weather widget.
@@ -588,6 +652,24 @@ Force-refresh cache and update tooltip without opening buffer."
'local-map wttrin--mode-line-map)))
(force-mode-line-update t))
+(defvar wttrin--buffer-refresh-timer nil
+ "Timer object for proactive buffer cache refresh.")
+
+(defun wttrin--buffer-cache-refresh ()
+ "Proactively refresh the buffer cache for `wttrin-favorite-location'.
+Fetches fresh weather data and updates the buffer cache entry without
+displaying anything. This keeps buffer data fresh for when the user
+opens the weather buffer."
+ (when wttrin-favorite-location
+ (let* ((location wttrin-favorite-location)
+ (cache-key (wttrin--make-cache-key location)))
+ (wttrin-fetch-raw-string
+ location
+ (lambda (fresh-data)
+ (when fresh-data
+ (wttrin--cleanup-cache-if-needed)
+ (puthash cache-key (cons (float-time) fresh-data) wttrin--cache)))))))
+
(defun wttrin--mode-line-start ()
"Start mode-line weather display and refresh timer."
(when (featurep 'wttrin-debug)
@@ -606,20 +688,30 @@ Force-refresh cache and update tooltip without opening buffer."
(run-at-time wttrin-mode-line-refresh-interval
wttrin-mode-line-refresh-interval
#'wttrin--mode-line-fetch-weather))
+ ;; Start buffer cache refresh timer
+ (when wttrin--buffer-refresh-timer
+ (cancel-timer wttrin--buffer-refresh-timer))
+ (setq wttrin--buffer-refresh-timer
+ (run-at-time wttrin-refresh-interval
+ wttrin-refresh-interval
+ #'wttrin--buffer-cache-refresh))
(when (featurep 'wttrin-debug)
(wttrin--debug-log "wttrin mode-line: Initial fetch scheduled in %s seconds, then every %s seconds"
wttrin-mode-line-startup-delay
wttrin-mode-line-refresh-interval))))
(defun wttrin--mode-line-stop ()
- "Stop mode-line weather display and cancel timer."
+ "Stop mode-line weather display and cancel timers."
(when (featurep 'wttrin-debug)
(wttrin--debug-log "wttrin mode-line: Stopping mode-line display"))
(when wttrin--mode-line-timer
(cancel-timer wttrin--mode-line-timer)
(setq wttrin--mode-line-timer nil))
+ (when wttrin--buffer-refresh-timer
+ (cancel-timer wttrin--buffer-refresh-timer)
+ (setq wttrin--buffer-refresh-timer nil))
(setq wttrin-mode-line-string nil)
- (setq wttrin--mode-line-tooltip-data nil)
+ (setq wttrin--mode-line-cache nil)
(force-mode-line-update t))
;;;###autoload