diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-01 22:57:03 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-01 22:57:03 -0400 |
| commit | 6c808ff4a4c54d543e065b722bc8517fe5141b45 (patch) | |
| tree | 5519b1dfeb9c798acb466bebbfe43807f9cc9911 | |
| parent | ad22b39fb5661a6662865c0e88dcfb0d1627eeb4 (diff) | |
| download | emacs-wttrin-6c808ff4a4c54d543e065b722bc8517fe5141b45.tar.gz emacs-wttrin-6c808ff4a4c54d543e065b722bc8517fe5141b45.zip | |
feat(mode-line): optional multi-day forecast in the weather tooltip
The tooltip showed current conditions only. I added wttrin-mode-line-tooltip-forecast-days (default 0, capped at wttr.in's three days). When positive, the mode-line refresh also fetches ?format=j1 and caches the parsed day list, and the tooltip renders one line per day between the conditions and the age line: Today / Tomorrow / weekday label, min-max temps in the configured unit, and the midday description. A failed or malformed forecast fetch keeps the previous forecast and never disturbs the main fetch.
| -rw-r--r-- | tests/test-wttrin--mode-line-forecast.el | 149 | ||||
| -rw-r--r-- | wttrin.el | 130 |
2 files changed, 276 insertions, 3 deletions
diff --git a/tests/test-wttrin--mode-line-forecast.el b/tests/test-wttrin--mode-line-forecast.el new file mode 100644 index 0000000..c371f94 --- /dev/null +++ b/tests/test-wttrin--mode-line-forecast.el @@ -0,0 +1,149 @@ +;;; test-wttrin--mode-line-forecast.el --- Tests for the tooltip forecast -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;;; Commentary: + +;; Unit tests for the mode-line tooltip forecast: parsing wttr.in's j1 JSON +;; into a day list, formatting up to `wttrin-mode-line-tooltip-forecast-days' +;; lines, and appending the block to `wttrin--mode-line-tooltip' output. + +;;; Code: + +(require 'ert) +(require 'wttrin) +(require 'testutil-wttrin) + +;;; Fixture + +(defconst test-wttrin--forecast-j1 + "{\"weather\": [ + {\"date\": \"2026-07-01\", \"mintempC\": \"22\", \"maxtempC\": \"31\", + \"mintempF\": \"71\", \"maxtempF\": \"88\", + \"hourly\": [{\"time\": \"0\", \"weatherDesc\": [{\"value\": \"Clear\"}]}, + {\"time\": \"1200\", \"weatherDesc\": [{\"value\": \"Partly cloudy\"}]}, + {\"time\": \"2100\", \"weatherDesc\": [{\"value\": \"Clear\"}]}]}, + {\"date\": \"2026-07-02\", \"mintempC\": \"20\", \"maxtempC\": \"26\", + \"mintempF\": \"68\", \"maxtempF\": \"79\", + \"hourly\": [{\"time\": \"1200\", \"weatherDesc\": [{\"value\": \"Light rain\"}]}]}, + {\"date\": \"2026-07-03\", \"mintempC\": \"21\", \"maxtempC\": \"31\", + \"mintempF\": \"70\", \"maxtempF\": \"88\", + \"hourly\": [{\"time\": \"1200\", \"weatherDesc\": [{\"value\": \"Sunny\"}]}]}]}" + "Trimmed wttr.in ?format=j1 response: three days, the keys the code reads.") + +(defun test-wttrin--forecast-days () + "Return the parsed day list from the fixture." + (wttrin--forecast-parse test-wttrin--forecast-j1)) + +;;; -------------------------------------------------------------------------- +;;; wttrin--forecast-parse +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin--forecast-parse-normal-three-days () + "Normal: the fixture parses into a list of three day alists." + (let ((days (test-wttrin--forecast-days))) + (should (= 3 (length days))) + (should (equal "2026-07-01" (alist-get 'date (car days)))))) + +(ert-deftest test-wttrin--forecast-parse-error-malformed-json () + "Error: malformed JSON returns nil instead of signaling." + (should-not (wttrin--forecast-parse "{not json"))) + +(ert-deftest test-wttrin--forecast-parse-error-missing-weather-key () + "Error: JSON without a weather array returns nil." + (should-not (wttrin--forecast-parse "{\"nearest_area\": []}"))) + +(ert-deftest test-wttrin--forecast-parse-boundary-nil-and-empty () + "Boundary: nil and empty-string input return nil." + (should-not (wttrin--forecast-parse nil)) + (should-not (wttrin--forecast-parse ""))) + +;;; -------------------------------------------------------------------------- +;;; wttrin--forecast-format +;;; -------------------------------------------------------------------------- + +(ert-deftest test-wttrin--forecast-format-normal-three-days-fahrenheit () + "Normal: unit system \"u\" renders three labeled lines with °F ranges." + (let* ((wttrin-unit-system "u") + (text (wttrin--forecast-format (test-wttrin--forecast-days) 3)) + (lines (split-string text "\n"))) + (should (= 3 (length lines))) + (should (string-match-p "\\`Today 71-88°F Partly cloudy\\'" (nth 0 lines))) + (should (string-match-p "\\`Tomorrow 68-79°F Light rain\\'" (nth 1 lines))) + ;; 2026-07-03 is a Friday; the third day is labeled by weekday. + (should (string-match-p "\\`Fri 70-88°F Sunny\\'" (nth 2 lines))))) + +(ert-deftest test-wttrin--forecast-format-normal-metric () + "Normal: a non-\"u\" unit system renders °C ranges." + (let* ((wttrin-unit-system "m") + (text (wttrin--forecast-format (test-wttrin--forecast-days) 2))) + (should (string-match-p "Today 22-31°C" text)) + (should (string-match-p "Tomorrow 20-26°C" text)))) + +(ert-deftest test-wttrin--forecast-format-boundary-day-counts () + "Boundary: 0 days is nil; 1 day is one line; >3 caps at the data." + (let ((wttrin-unit-system "u") + (days (test-wttrin--forecast-days))) + (should-not (wttrin--forecast-format days 0)) + (should (= 1 (length (split-string (wttrin--forecast-format days 1) "\n")))) + (should (= 3 (length (split-string (wttrin--forecast-format days 5) "\n")))))) + +(ert-deftest test-wttrin--forecast-format-boundary-missing-hourly-desc () + "Boundary: a day with no hourly data renders the temps without a description." + (let* ((wttrin-unit-system "u") + (day '((date . "2026-07-01") (mintempF . "71") (maxtempF . "88") + (mintempC . "22") (maxtempC . "31") (hourly . ()))) + (text (wttrin--forecast-format (list day) 1))) + (should (equal "Today 71-88°F" text)))) + +(ert-deftest test-wttrin--forecast-format-error-nil-days () + "Error: nil day list returns nil." + (should-not (wttrin--forecast-format nil 3))) + +;;; -------------------------------------------------------------------------- +;;; tooltip integration +;;; -------------------------------------------------------------------------- + +(defun test-wttrin--forecast-tooltip-fixture (option-days) + "Return the tooltip with fresh current + forecast caches and OPTION-DAYS." + (let ((wttrin-unit-system "u") + (wttrin-mode-line-tooltip-forecast-days option-days) + (wttrin--mode-line-cache (cons (float-time) "Paris: ☀️ +61°F Clear")) + (wttrin--mode-line-forecast-cache + (cons (float-time) (test-wttrin--forecast-days)))) + (wttrin--mode-line-tooltip))) + +(ert-deftest test-wttrin--forecast-tooltip-normal-appends-block () + "Normal: with the option at 3, the tooltip carries the forecast lines." + (testutil-wttrin-setup) + (unwind-protect + (let ((tooltip (test-wttrin--forecast-tooltip-fixture 3))) + (should (string-match-p "Paris" tooltip)) + (should (string-match-p "Today 71-88°F Partly cloudy" tooltip)) + (should (string-match-p "Fri 70-88°F Sunny" tooltip)) + (should (string-match-p "Updated" tooltip))) + (testutil-wttrin-teardown))) + +(ert-deftest test-wttrin--forecast-tooltip-boundary-option-zero-unchanged () + "Boundary: with the option at 0 (default), the tooltip has no forecast." + (testutil-wttrin-setup) + (unwind-protect + (let ((tooltip (test-wttrin--forecast-tooltip-fixture 0))) + (should (string-match-p "Paris" tooltip)) + (should-not (string-match-p "Today" tooltip))) + (testutil-wttrin-teardown))) + +(ert-deftest test-wttrin--forecast-tooltip-boundary-empty-forecast-cache () + "Boundary: option on but no forecast cache yet leaves the tooltip as before." + (testutil-wttrin-setup) + (unwind-protect + (let ((wttrin-mode-line-tooltip-forecast-days 3) + (wttrin--mode-line-cache (cons (float-time) "Paris: ☀️ +61°F Clear")) + (wttrin--mode-line-forecast-cache nil)) + (let ((tooltip (wttrin--mode-line-tooltip))) + (should (string-match-p "Paris" tooltip)) + (should-not (string-match-p "Today" tooltip)))) + (testutil-wttrin-teardown))) + +(provide 'test-wttrin--mode-line-forecast) +;;; test-wttrin--mode-line-forecast.el ends here @@ -35,6 +35,7 @@ (require 'face-remap) (require 'subr-x) ; string-trim +(require 'json) ; forecast j1 parsing (require 'url) ;; Declare xterm-color functions (loaded on-demand) @@ -338,6 +339,17 @@ wastes their bandwidth. Be kind to the free service." :group 'wttrin :type 'integer) +(defcustom wttrin-mode-line-tooltip-forecast-days 0 + "Days of forecast to append to the mode-line weather tooltip. +0 (the default) keeps the tooltip current-conditions-only. A positive +value appends one line per day: the remainder of today, tomorrow, and +the day after, labeled Today / Tomorrow / weekday. wttr.in's JSON feed +carries at most three days, so larger values are capped at what the +feed returns. When non-zero, the mode-line refresh performs a second +fetch (?format=j1) for the forecast data." + :group 'wttrin + :type 'integer) + (defcustom wttrin-mode-line-startup-delay 3 "Seconds to delay initial mode-line weather fetch after Emacs starts. This allows network stack and daemon initialization to complete before @@ -432,6 +444,14 @@ Set this to t BEFORE loading wttrin, typically in your init file: 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-forecast-cache nil + "Cached forecast data as a (timestamp . DAY-LIST) cons cell, or nil. +When non-nil, car is the `float-time' of the fetch and cdr is the parsed +day list from `wttrin--forecast-parse'. The raw day list is cached (not +rendered text) so `wttrin--mode-line-tooltip' formats it at hover time +against the current `wttrin-mode-line-tooltip-forecast-days' and +`wttrin-unit-system' values.") + (defvar wttrin--mode-line-rendered-stale nil "Whether the mode-line emoji is currently rendered as stale (dimmed).") @@ -1610,6 +1630,7 @@ proceeds normally." (let ((location (wttrin--resolve-favorite-location))) (if (not location) (wttrin--debug-log "mode-line-fetch: No favorite location available, skipping") + (wttrin--mode-line-fetch-forecast location) (let* (;; wttr.in format codes: %l=location %c=emoji %t=temp %C=conditions (format-params (if wttrin-unit-system (concat "?" wttrin-unit-system "&format=%l:+%c+%t+%C") @@ -1659,25 +1680,128 @@ without a separate guard." (let ((age (- (float-time) (car cache-entry)))) (> age (* 2 wttrin-mode-line-refresh-interval))))) +(defun wttrin--forecast-parse (json-string) + "Parse a wttr.in ?format=j1 JSON-STRING into a list of day alists. +Returns the top-level weather array as a list (one alist per day), or +nil when JSON-STRING is nil, empty, malformed, or missing the weather +key. Never signals -- a broken forecast response must not take the +mode-line fetch down with it." + (when (and (stringp json-string) (> (length json-string) 0)) + (condition-case err + (let ((json-object-type 'alist) + (json-array-type 'list) + (json-key-type 'symbol)) + (alist-get 'weather (json-read-from-string json-string))) + (error + (wttrin--debug-log "forecast-parse: %s" (error-message-string err)) + nil)))) + +(defun wttrin--forecast-day-label (day index) + "Return the tooltip label for DAY (a day alist) at INDEX in the forecast. +Index 0 is \"Today\" and 1 is \"Tomorrow\"; later days use the abbreviated +weekday name from DAY's date field (\"Fri\"). Falls back to the raw date +string when it doesn't parse, and to the index when there's no date." + (let ((date (alist-get 'date day))) + (cond + ((= index 0) "Today") + ((= index 1) "Tomorrow") + ((stringp date) + (condition-case nil + (let ((parsed (parse-time-string date))) + (format-time-string + "%a" (encode-time 0 0 12 (nth 3 parsed) (nth 4 parsed) + (nth 5 parsed)))) + (error date))) + (t (format "Day %d" (1+ index)))))) + +(defun wttrin--forecast-midday-desc (day) + "Return DAY's midday weather description, or nil when unavailable. +Reads the hourly entry whose time is \"1200\" (falling back to the middle +entry) and returns the first weatherDesc value." + (let* ((hourly (alist-get 'hourly day)) + (midday (or (seq-find (lambda (h) (equal (alist-get 'time h) "1200")) + hourly) + (nth (/ (length hourly) 2) hourly)))) + (when midday + (let ((desc (alist-get 'value (car (alist-get 'weatherDesc midday))))) + ;; wttr.in pads some description values with trailing whitespace. + (when (stringp desc) (string-trim desc)))))) + +(defun wttrin--forecast-format (days count) + "Format up to COUNT entries of DAYS (parsed day alists) for the tooltip. +One line per day: \"<label> <min>-<max>°F <midday description>\" -- °F when +`wttrin-unit-system' is \"u\", otherwise °C (both are present in the j1 +data; wttr.in's own location-based default can't be known here). The +description is omitted when the day has no hourly data. Returns nil when +DAYS is nil or COUNT is not positive." + (when (and days (> count 0)) + (let* ((fahrenheit (equal wttrin-unit-system "u")) + (min-key (if fahrenheit 'mintempF 'mintempC)) + (max-key (if fahrenheit 'maxtempF 'maxtempC)) + (unit (if fahrenheit "°F" "°C")) + (index -1)) + (mapconcat + (lambda (day) + (setq index (1+ index)) + (let ((desc (wttrin--forecast-midday-desc day))) + (concat (wttrin--forecast-day-label day index) + (format " %s-%s%s" + (alist-get min-key day) + (alist-get max-key day) + unit) + (when desc (concat " " desc))))) + (seq-take days count) + "\n")))) + +(defun wttrin--mode-line-fetch-forecast (location) + "Fetch the j1 forecast for LOCATION into `wttrin--mode-line-forecast-cache'. +Fires only when `wttrin-mode-line-tooltip-forecast-days' is positive. +On a failed fetch or unparseable response the previous cache is kept -- +the tooltip just shows whatever forecast it last had." + (when (> wttrin-mode-line-tooltip-forecast-days 0) + (let ((url (concat "https://wttr.in/" + (url-hexify-string location) + "?format=j1"))) + (wttrin--debug-log "forecast-fetch: URL = %s" url) + (wttrin--fetch-url + url + (lambda (data &optional _error-msg) + (let ((days (wttrin--forecast-parse data))) + (if days + (setq wttrin--mode-line-forecast-cache + (cons (float-time) days)) + (wttrin--debug-log + "forecast-fetch: no usable forecast, keeping previous")))))))) + (defun wttrin--mode-line-tooltip (&optional _window _object _pos) "Compute tooltip text from `wttrin--mode-line-cache'. Calculates age at call time so the tooltip is always current. If staleness has changed since the last render, triggers a re-render so the emoji dimming matches. +When `wttrin-mode-line-tooltip-forecast-days' is positive and a forecast +has been fetched, the forecast lines sit between the conditions and the +age line. Optional arguments are ignored (required by `help-echo' function protocol)." (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 (wttrin--mode-line-stale-p wttrin--mode-line-cache)) - (age-str (wttrin--format-age age))) + (age-str (wttrin--format-age age)) + (forecast (when (> wttrin-mode-line-tooltip-forecast-days 0) + (wttrin--forecast-format + (cdr wttrin--mode-line-forecast-cache) + wttrin-mode-line-tooltip-forecast-days))) + (head (if forecast + (concat weather-string "\n" forecast) + weather-string))) ;; Re-render emoji if staleness state has changed (unless (eq stale-p wttrin--mode-line-rendered-stale) (wttrin--mode-line-update-display)) (if stale-p (format "%s\nStale: updated %s — fetch failed, will retry" - weather-string age-str) - (format "%s\nUpdated %s" weather-string age-str))))) + head age-str) + (format "%s\nUpdated %s" head age-str))))) (defun wttrin--mode-line-update-display () "Update mode-line display from `wttrin--mode-line-cache'. |
