diff options
| author | Craig Jennings <c@cjennings.net> | 2026-02-21 07:06:50 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-02-21 07:11:03 -0600 |
| commit | b74b98f177d92d50ddbede900ba41212e07c5f63 (patch) | |
| tree | 459b1630dcc7d1c941f850565acdc16332831948 | |
| parent | ec8130cfe1a7390e9939b311c8db39907a3f7f44 (diff) | |
| download | emacs-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.org | 14 | ||||
| -rw-r--r-- | tests/test-integration-debug.el | 8 | ||||
| -rw-r--r-- | tests/test-wttrin--format-staleness-header.el | 80 | ||||
| -rw-r--r-- | tests/test-wttrin--get-cached-or-fetch.el | 109 | ||||
| -rw-r--r-- | tests/test-wttrin--mode-line-map.el | 11 | ||||
| -rw-r--r-- | tests/test-wttrin--mode-line-update-display.el | 275 | ||||
| -rw-r--r-- | tests/test-wttrin-smoke.el | 2 | ||||
| -rw-r--r-- | tests/testutil-wttrin.el | 15 | ||||
| -rw-r--r-- | wttrin.el | 212 |
9 files changed, 479 insertions, 247 deletions
@@ -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 () @@ -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 |
