diff --git a/bin/omarchy-weather-icon b/bin/omarchy-weather-icon index 1171ed03..ffe1a9df 100755 --- a/bin/omarchy-weather-icon +++ b/bin/omarchy-weather-icon @@ -2,7 +2,16 @@ # omarchy:summary=Returns a weather condition icon, adjusted for live sunrise and sunset. -weather_data=$(curl -fsS --max-time 3 "https://wttr.in?format=j1" 2>/dev/null | jq -er '[.current_condition[0].weatherCode, .weather[0].astronomy[0].sunrise, .weather[0].astronomy[0].sunset] | select(all(. != null and . != "")) | @tsv' 2>/dev/null) || exit 1 +# Only consult the helper when a location is stored: this runs every minute, +# and the helper's dynamic fallback would cost an extra wttr.in request when +# nothing is set — the bare j1 fetch already auto-detects by IP. +query="" +if [[ -s "$HOME/.local/state/omarchy/settings/weather.json" ]]; then + location=$(omarchy-weather-location 2>/dev/null) + [[ -n $location ]] && query=$(jq -rn --arg location "$location" '$location | @uri') +fi + +weather_data=$(curl -fsS --max-time 3 "https://wttr.in/${query}?format=j1" 2>/dev/null | jq -er '[.current_condition[0].weatherCode, .weather[0].astronomy[0].sunrise, .weather[0].astronomy[0].sunset] | select(all(. != null and . != "")) | @tsv' 2>/dev/null) || exit 1 IFS=$'\t' read -r weather_code sunrise sunset <<< "$weather_data" if [[ ! $weather_code =~ ^[0-9]+$ || ! $sunrise =~ ^[0-9]{1,2}:[0-9]{2}\ [AP]M$ || ! $sunset =~ ^[0-9]{1,2}:[0-9]{2}\ [AP]M$ ]]; then diff --git a/bin/omarchy-weather-location b/bin/omarchy-weather-location new file mode 100755 index 00000000..13bea6fb --- /dev/null +++ b/bin/omarchy-weather-location @@ -0,0 +1,46 @@ +#!/bin/bash + +# omarchy:summary=Show or set the location used for weather reports +# +# State lives in ~/.local/state/omarchy/settings/weather.json as +# {"name": ..., "latitude": ..., "longitude": ...}. A missing file means the +# location is auto-detected from the IP address. Coordinates make the weather +# panel exact; a hand-written {"name": "Malibu"} alone works too. +# +# (no args) the current location: the stored name, or the +# IP-detected city when nothing is set +# --set [lat,lon] store a location +# --clear return to IP auto-detect + +LOC_FILE="$HOME/.local/state/omarchy/settings/weather.json" +COORDS_PATTERN='^(-?[0-9]+(\.[0-9]+)?),(-?[0-9]+(\.[0-9]+)?)$' + +case "${1:-}" in +"") + name="" + [[ -f $LOC_FILE ]] && name=$(jq -r '.name // "" | if type == "string" then . else "" end' "$LOC_FILE" 2>/dev/null) + if [[ -z $name ]]; then + name=$(curl -fsS --max-time 4 "https://wttr.in/?format=%l" 2>/dev/null) + name=${name%%,*} + fi + [[ -n $name ]] && echo "$name" + ;; +--set) + [[ -n ${2:-} ]] || { echo "Usage: omarchy-weather-location --set [lat,lon]" >&2; exit 1; } + if [[ -n ${3:-} ]]; then + [[ ${3} =~ $COORDS_PATTERN ]] || { echo "Invalid coordinates: $3 (expected lat,lon)" >&2; exit 1; } + location=$(jq -n --arg name "$2" --argjson latitude "${3%,*}" --argjson longitude "${3#*,}" '{$name, $latitude, $longitude}') + else + location=$(jq -n --arg name "$2" '{$name}') + fi + mkdir -p "$(dirname "$LOC_FILE")" + echo "$location" >"$LOC_FILE" + ;; +--clear) + rm -f "$LOC_FILE" + ;; +*) + echo "Usage: omarchy-weather-location [--set [lat,lon]|--clear]" >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-weather-status b/bin/omarchy-weather-status index f0cf0741..0590cd78 100755 --- a/bin/omarchy-weather-status +++ b/bin/omarchy-weather-status @@ -2,15 +2,22 @@ # omarchy:summary=Returns a formatted weather status string with temperature and wind speed. -weather=$(curl -fsS --max-time 4 "https://wttr.in?format=%l|%t|%w" 2>/dev/null | tr -d '\n') +place=$(omarchy-weather-location 2>/dev/null) + +if [[ -z $place ]]; then + echo "Weather unavailable" + exit 1 +fi + +query=$(jq -rn --arg place "$place" '$place | @uri') +weather=$(curl -fsS --max-time 4 "https://wttr.in/${query}?format=%t|%w" 2>/dev/null | tr -d '\n') if [[ -z $weather ]]; then echo "Weather unavailable" exit 1 fi -IFS='|' read -r place temperature wind <<< "$weather" -place=${place%%,*} +IFS='|' read -r temperature wind <<< "$weather" place=${place^} temperature=${temperature#+} diff --git a/shell/plugins/panels/weather/Model.js b/shell/plugins/panels/weather/Model.js index 7e79b88b..1a6a8e6b 100644 --- a/shell/plugins/panels/weather/Model.js +++ b/shell/plugins/panels/weather/Model.js @@ -10,6 +10,65 @@ function parseWeatherStatus(raw) { } } +// weather.json holds {"name": ..., "latitude": ..., "longitude": ...} (see +// omarchy-weather-location, which owns the format). Missing, blank, or +// unparseable means the location is auto-detected from the IP address. +function parseLocationFile(raw) { + var unset = { name: "", latitude: null, longitude: null } + try { + var data = JSON.parse(String(raw || "")) + if (!data || typeof data !== "object") return unset + + var latitude = parseFloat(data.latitude) + var longitude = parseFloat(data.longitude) + var hasCoordinates = !isNaN(latitude) && !isNaN(longitude) + return { + name: typeof data.name === "string" ? data.name.replace(/^\s+|\s+$/g, "") : "", + latitude: hasCoordinates ? latitude : null, + longitude: hasCoordinates ? longitude : null + } + } catch (e) { + return unset + } +} + +// wttr.in path segment for a configured location: exact coordinates when +// both are present, the URL-encoded name as a fallback (hand-edited +// weather.loc files may only carry a name), empty for IP auto-detect. +function wttrLocationQuery(location, latitude, longitude) { + var lat = parseFloat(String(latitude)) + var lon = parseFloat(String(longitude)) + if (!isNaN(lat) && !isNaN(lon)) return lat + "," + lon + + var name = String(location || "").replace(/^\s+|\s+$/g, "") + return name === "" ? "" : encodeURIComponent(name) +} + +// Open-Meteo geocoding response → suggestion rows for the location picker. +function parseGeocodingResults(raw) { + try { + var data = JSON.parse(String(raw || "{}")) + var results = data.results + if (!results || !results.length) return [] + + var out = [] + for (var i = 0; i < results.length; i++) { + var r = results[i] + if (!r || !r.name || r.latitude === undefined || r.longitude === undefined) continue + var region = [r.admin1, r.country].filter(function(part) { return !!part }).join(", ") + out.push({ + name: String(r.name), + description: region, + latitude: r.latitude, + longitude: r.longitude + }) + } + return out + } catch (e) { + return [] + } +} + function isFutureForecastDate(dateString, todayString) { if (!dateString) return false return String(dateString).slice(0, 10) > String(todayString || "") @@ -94,6 +153,24 @@ function openMeteoForecastDays(dailyForecastReport, todayString) { return result } +// Open-Meteo bundles current conditions with the daily forecast request and +// answers far faster than wttr.in. Normalize them to wttr's +// current_condition shape so the panel can use either source +// interchangeably. Open-Meteo reports metric (°C, km/h). +function openMeteoCurrentCondition(dailyForecastReport) { + var current = dailyForecastReport && dailyForecastReport.current ? dailyForecastReport.current : null + if (!current || current.temperature_2m === undefined || current.temperature_2m === null) return null + return { + temp_C: roundedTemp(current.temperature_2m), + temp_F: roundedTemp(celsiusToFahrenheit(current.temperature_2m)), + FeelsLikeC: roundedTemp(current.apparent_temperature), + FeelsLikeF: roundedTemp(celsiusToFahrenheit(current.apparent_temperature)), + windspeedKmph: roundedTemp(current.wind_speed_10m), + windspeedMiles: roundedTemp(current.wind_speed_10m * 0.621371), + humidity: roundedTemp(current.relative_humidity_2m) + } +} + function wttrNextForecastDays(report, todayString) { var days = report && report.weather ? report.weather : [] var result = [] @@ -170,6 +247,9 @@ function iconForCode(code, night) { if (typeof module !== "undefined") { module.exports = { parseWeatherStatus: parseWeatherStatus, + parseLocationFile: parseLocationFile, + wttrLocationQuery: wttrLocationQuery, + parseGeocodingResults: parseGeocodingResults, isFutureForecastDate: isFutureForecastDate, roundedTemp: roundedTemp, celsiusToFahrenheit: celsiusToFahrenheit, @@ -180,6 +260,7 @@ if (typeof module !== "undefined") { shouldUseImperial: shouldUseImperial, dayName: dayName, openMeteoForecastDays: openMeteoForecastDays, + openMeteoCurrentCondition: openMeteoCurrentCondition, wttrNextForecastDays: wttrNextForecastDays, buildForecastDays: buildForecastDays, bareTempForDay: bareTempForDay, diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index 991beb39..eae1416c 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -31,6 +31,7 @@ Panel { function close() { setCenterHoverRevealSuppressed(false) + if (root.editingLocation) root.cancelEditingLocation() root.controller.hide() } @@ -49,6 +50,55 @@ Panel { property var dailyForecastReport: null property string wttrLocation: "" + // Configured location, read from the weather.json state file (owned by + // omarchy-weather-location). The query is the wttr.in path segment + // (coordinates when stored, else the encoded name); empty means IP + // auto-detect. The watch makes hand edits take effect live. + property var configuredLocationState: ({ name: "", latitude: null, longitude: null }) + readonly property string configuredLocation: configuredLocationState.name + readonly property string locationQuery: Model.wttrLocationQuery(configuredLocationState.name, configuredLocationState.latitude, configuredLocationState.longitude) + + // A location change makes the previous report misleading (the old city's + // numbers under the new label), so drop it, abort any in-flight fetch for + // the old location, and refetch from scratch. + onLocationQueryChanged: { + report = null + dailyForecastReport = null + wttrLocation = "" + forecastRetries = 0 + forecastProc.running = false + dailyForecastProc.running = false + Qt.callLater(refresh) + } + + property FileView locationFile: FileView { + path: Quickshell.env("HOME") + "/.local/state/omarchy/settings/weather.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.configuredLocationState = Model.parseLocationFile(text()) + onLoadFailed: root.configuredLocationState = Model.parseLocationFile("") + } + + // The first read can race shell startup (observed sporadically), leaving a + // stored location unhonored until the next file write. One delayed reload + // self-corrects; if the first read was fine it's a no-op, since identical + // state doesn't change locationQuery and so triggers no refetch. + Timer { + interval: 1500 + running: true + onTriggered: locationFile.reload() + } + + property int forecastRetries: 0 + + // Click-to-edit state for the location label. + property bool editingLocation: false + property var locationSuggestions: [] + property int suggestionIndex: 0 + property string geocodePendingQuery: "" + property string geocodeActiveQuery: "" + // Bar pill state. Polled locally; populated by weatherProc below. property string label: "" property string klass: "" @@ -59,7 +109,9 @@ Panel { klass = data.klass } - readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null + // wttr's current conditions when available; open-meteo's (bundled with the + // much faster daily forecast fetch) fill the hero while wttr is in flight. + readonly property var current: (report && report.current_condition && report.current_condition[0]) ? report.current_condition[0] : Model.openMeteoCurrentCondition(dailyForecastReport) readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null readonly property var forecastDays: buildForecastDays() readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : "" @@ -69,7 +121,7 @@ Panel { // Auto-refresh interval in minutes; clamped to a sane minimum. readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15) - readonly property string reportLocation: wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "") + readonly property string reportLocation: configuredLocation || wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "") readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : "" readonly property string tempUnit: "°" + (useImperial ? "F" : "C") readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : "" @@ -78,27 +130,104 @@ Panel { function refresh() { if (!forecastProc.running) forecastProc.running = true - if (!locationProc.running) locationProc.running = true + if (root.locationQuery === "" && !locationProc.running) locationProc.running = true + // With stored coordinates this fetches open-meteo right away — no need + // to wait for the slow wttr response. Without them it's a no-op until + // wttr reports the detected area. + refreshDailyForecast(null) } function refreshDailyForecast(sourceReport) { - var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo - if (!area || dailyForecastProc.running) return + if (dailyForecastProc.running) return - var lat = parseFloat(String(area.latitude || "")) - var lon = parseFloat(String(area.longitude || "")) + var lat = parseFloat(String(root.configuredLocationState.latitude)) + var lon = parseFloat(String(root.configuredLocationState.longitude)) + if (isNaN(lat) || isNaN(lon)) { + var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo + if (!area) return + lat = parseFloat(String(area.latitude || "")) + lon = parseFloat(String(area.longitude || "")) + } if (isNaN(lat) || isNaN(lon)) return var url = "https://api.open-meteo.com/v1/forecast" + "?latitude=" + encodeURIComponent(String(lat)) + "&longitude=" + encodeURIComponent(String(lon)) + "&daily=weather_code,temperature_2m_max,temperature_2m_min" + + "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m" + "&forecast_days=4" + "&timezone=auto" dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url] dailyForecastProc.running = true } + // ---- Location editing. Clicking the location label swaps it for a search + // field; picking a geocoded suggestion persists name + coordinates to + // the module's shell.json entry. An empty commit returns to auto. + function startEditingLocation() { + editingLocation = true + locationSuggestions = [] + suggestionIndex = 0 + Qt.callLater(function() { + locationField.text = root.configuredLocation + locationField.selectAll() + locationField.forceActiveFocus() + }) + } + + function cancelEditingLocation() { + editingLocation = false + locationSuggestions = [] + geocodeDebounce.stop() + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + + function commitLocation() { + if (locationField.text.trim() === "") { + clearLocation() + return + } + pickSuggestion(locationSuggestions[Math.min(suggestionIndex, locationSuggestions.length - 1)]) + } + + function clearLocation() { + persistLocation("", null, null) + wttrLocation = "" + cancelEditingLocation() + } + + function pickSuggestion(suggestion) { + if (!suggestion) return + persistLocation(suggestion.name, suggestion.latitude, suggestion.longitude) + cancelEditingLocation() + } + + function persistLocation(name, latitude, longitude) { + if (name) + Quickshell.execDetached(["omarchy-weather-location", "--set", name, latitude + "," + longitude]) + else + Quickshell.execDetached(["omarchy-weather-location", "--clear"]) + } + + // Debounced geocoding. Only one curl runs at a time; if the query moved on + // while a fetch was in flight, the latest query is fetched right after. + function requestGeocode() { + var query = locationField.text.trim() + if (query.length < 2) { + locationSuggestions = [] + return + } + geocodePendingQuery = query + if (!geocodeProc.running) startGeocode() + } + + function startGeocode() { + geocodeActiveQuery = geocodePendingQuery + geocodeProc.command = ["curl", "-fsS", "--max-time", "5", + "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(geocodeActiveQuery) + "&count=5&language=en&format=json"] + geocodeProc.running = true + } + function buildForecastDays() { return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd")) } @@ -152,23 +281,45 @@ Panel { Process { id: forecastProc - command: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"] + command: ["curl", "-fsS", "--max-time", "10", "https://wttr.in/" + root.locationQuery + "?format=j1"] stdout: StdioCollector { waitForEnd: true onStreamFinished: { var raw = String(text || "").trim() - if (!raw) return + if (!raw) { + root.scheduleForecastRetry() + return + } try { var parsed = JSON.parse(raw) root.report = parsed - root.refreshDailyForecast(parsed) + root.forecastRetries = 0 + // Stored coordinates already drove the fast open-meteo fetch from + // refresh(); only auto-detect needs the area wttr reported. + if (isNaN(parseFloat(String(root.configuredLocationState.latitude)))) + root.refreshDailyForecast(parsed) } catch (e) { - // Keep last-good report on parse failure so the popup isn't blanked. + // Keep last-good report visible, but try again shortly. + root.scheduleForecastRetry() } } } } + // wttr.in can be slow or flaky, especially for a location it hasn't + // cached yet. Retry a few times before leaving it to the refresh timer. + function scheduleForecastRetry() { + if (forecastRetries >= 3) return + forecastRetries++ + forecastRetryTimer.restart() + } + + Timer { + id: forecastRetryTimer + interval: 2500 + onTriggered: if (!forecastProc.running) forecastProc.running = true + } + Process { id: dailyForecastProc stdout: StdioCollector { @@ -185,9 +336,27 @@ Panel { } } + Process { + id: geocodeProc + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + root.locationSuggestions = root.editingLocation ? Model.parseGeocodingResults(text) : [] + root.suggestionIndex = 0 + if (root.geocodePendingQuery !== root.geocodeActiveQuery) Qt.callLater(root.startGeocode) + } + } + } + + Timer { + id: geocodeDebounce + interval: 300 + onTriggered: root.requestGeocode() + } + Process { id: locationProc - command: ["bash", "-lc", "curl -fsS --max-time 4 'https://wttr.in?format=%l' 2>/dev/null"] + command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"] stdout: StdioCollector { waitForEnd: true onStreamFinished: { @@ -215,6 +384,7 @@ Panel { function show(): void { root.openFromHotkey() } function hide(): void { root.close() } function toggle(): void { root.toggle() } + function edit(): void { root.openFromHotkey(); root.startEditingLocation() } } KeyboardPanel { @@ -231,6 +401,7 @@ Panel { PanelKeyCatcher { id: keyCatcher anchors.fill: parent + blocked: root.editingLocation onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } @@ -305,9 +476,16 @@ Panel { spacing: Style.space(12) Row { - visible: root.reportLocation !== "" + visible: !root.editingLocation && root.reportLocation !== "" spacing: Style.space(6) + TapHandler { + onTapped: root.startEditingLocation() + } + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + Text { text: "" // nf-fa-map_marker color: Qt.darker(root.bar.foreground, 1.4) @@ -325,6 +503,63 @@ Panel { } } + Row { + visible: root.editingLocation + spacing: Style.space(6) + + TextField { + id: locationField + width: Style.space(190) + placeholderText: "Search city" + foreground: root.bar.foreground + font.family: root.bar.fontFamily + + onTextChanged: if (root.editingLocation) geocodeDebounce.restart() + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + root.cancelEditingLocation() + event.accepted = true + } else if (event.key === Qt.Key_Down) { + if (root.suggestionIndex < root.locationSuggestions.length - 1) root.suggestionIndex++ + event.accepted = true + } else if (event.key === Qt.Key_Up) { + if (root.suggestionIndex > 0) root.suggestionIndex-- + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.commitLocation() + event.accepted = true + } + } + } + + // Clear back to IP auto-detect. Committing an empty field does + // the same for keyboard users. + Rectangle { + width: Style.space(18) + height: Style.space(18) + anchors.verticalCenter: parent.verticalCenter + radius: Math.min(4, Style.cornerRadius) + color: clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" + + Text { + anchors.centerIn: parent + text: "✕" + font.family: root.bar.fontFamily + color: Qt.darker(root.bar.foreground, 1.4) + font.pixelSize: Style.font.bodySmall + } + + MouseArea { + id: clearLocationArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clearLocation() + } + } + } + Row { visible: !!root.current spacing: Style.space(36) @@ -383,6 +618,57 @@ Panel { } } + // ---- Geocoding suggestions while the location is being edited. + Column { + visible: root.editingLocation && root.locationSuggestions.length > 0 + width: parent.width + spacing: 0 + + Repeater { + model: root.locationSuggestions + + Rectangle { + required property var modelData + required property int index + width: parent.width + height: suggestionRow.implicitHeight + Style.space(12) + radius: Style.cornerRadius + color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent" + + Row { + id: suggestionRow + anchors.left: parent.left + anchors.leftMargin: Style.space(16) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(8) + + Text { + text: modelData.name + color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.body + } + Text { + visible: text !== "" + text: modelData.description + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPositionChanged: root.suggestionIndex = index + onClicked: root.pickSuggestion(modelData) + } + } + } + } + Text { visible: !root.current text: "Fetching forecast…" diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index 4f6e4ebe..a06788b4 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -14,6 +14,38 @@ assertDeepEqual( ) assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON') +assertDeepEqual(weather.parseLocationFile('{"name": "Malibu", "latitude": 34.02577, "longitude": -118.7804}\n'), { name: 'Malibu', latitude: 34.02577, longitude: -118.7804 }, 'weather parses name plus coordinates from weather.json') +assertDeepEqual(weather.parseLocationFile('{"name": "New York"}'), { name: 'New York', latitude: null, longitude: null }, 'weather parses a name-only weather.json') +assertDeepEqual(weather.parseLocationFile('{"name": "Malibu", "latitude": 34.02577}'), { name: 'Malibu', latitude: null, longitude: null }, 'weather requires both coordinates') +assertDeepEqual(weather.parseLocationFile('not json'), { name: '', latitude: null, longitude: null }, 'weather treats an unparseable weather.json as auto-detect') +assertDeepEqual(weather.parseLocationFile(''), { name: '', latitude: null, longitude: null }, 'weather treats a missing weather.json as auto-detect') + +assertEqual(weather.wttrLocationQuery('Malibu', 34.02577, -118.7804), '34.02577,-118.7804', 'weather prefers coordinates for the wttr query') +assertEqual(weather.wttrLocationQuery('Malibu', '34.02577', '-118.7804'), '34.02577,-118.7804', 'weather accepts string coordinates') +assertEqual(weather.wttrLocationQuery('New York', null, null), 'New%20York', 'weather URL-encodes a name-only location') +assertEqual(weather.wttrLocationQuery('Malibu', 'nope', -118.7804), 'Malibu', 'weather ignores unparseable coordinates') +assertEqual(weather.wttrLocationQuery('', null, null), '', 'weather falls back to IP auto-detect without a location') +assertEqual(weather.wttrLocationQuery(' ', null, null), '', 'weather treats a blank location as unset') + +assertDeepEqual( + weather.parseGeocodingResults(JSON.stringify({ + results: [ + { name: 'Malibu', latitude: 34.02577, longitude: -118.7804, admin1: 'California', country: 'United States' }, + { name: 'Malibu', latitude: -7.18333, longitude: 29.65, admin1: 'Tanganyika', country: 'Democratic Republic of Congo' }, + { name: 'Broken', latitude: 1.0 }, + { name: 'Bare', latitude: 2.0, longitude: 3.0 } + ] + })), + [ + { name: 'Malibu', description: 'California, United States', latitude: 34.02577, longitude: -118.7804 }, + { name: 'Malibu', description: 'Tanganyika, Democratic Republic of Congo', latitude: -7.18333, longitude: 29.65 }, + { name: 'Bare', description: '', latitude: 2.0, longitude: 3.0 } + ], + 'weather parses geocoding suggestions and drops incomplete rows' +) +assertDeepEqual(weather.parseGeocodingResults('{}'), [], 'weather handles empty geocoding responses') +assertDeepEqual(weather.parseGeocodingResults('{'), [], 'weather handles invalid geocoding JSON') + assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures') assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures') assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures') @@ -49,6 +81,14 @@ assertDeepEqual( 'weather builds future Open-Meteo forecast days' ) +assertDeepEqual( + weather.openMeteoCurrentCondition({ current: { temperature_2m: 21.4, apparent_temperature: 19.8, wind_speed_10m: 14.3, relative_humidity_2m: 63 } }), + { temp_C: '21', temp_F: '71', FeelsLikeC: '20', FeelsLikeF: '68', windspeedKmph: '14', windspeedMiles: '9', humidity: '63' }, + 'weather normalizes open-meteo current conditions to the wttr shape' +) +assertEqual(weather.openMeteoCurrentCondition({}), null, 'weather returns no current conditions without open-meteo data') +assertEqual(weather.openMeteoCurrentCondition({ current: {} }), null, 'weather requires a current temperature') + const wttr = { weather: [ { date: '2026-05-25', maxtempC: '20', mintempC: '12' }, @@ -66,3 +106,31 @@ assertEqual( 'weather picks hourly forecast icon nearest noon' ) JS + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +weather_location() { + HOME="$test_tmp" "$ROOT/bin/omarchy-weather-location" "$@" +} + +weather_location --set "Malibu" "34.02577,-118.7804" +[[ $(jq -c . "$test_tmp/.local/state/omarchy/settings/weather.json") == '{"name":"Malibu","latitude":34.02577,"longitude":-118.7804}' ]] || fail "weather location stores name and coordinates as JSON" +pass "weather location stores name and coordinates as JSON" + +[[ $(weather_location) == "Malibu" ]] || fail "weather location returns the stored name" +pass "weather location returns the stored name" + +weather_location --set "New York" +[[ $(jq -c . "$test_tmp/.local/state/omarchy/settings/weather.json") == '{"name":"New York"}' ]] || fail "weather location stores a bare name as JSON" +[[ $(weather_location) == "New York" ]] || fail "weather location returns a bare stored name" +pass "weather location stores and returns a bare name" + +if weather_location --set "bad" "not,coords" 2>/dev/null; then + fail "weather location rejects malformed coordinates" +fi +pass "weather location rejects malformed coordinates" + +weather_location --clear +[[ ! -e "$test_tmp/.local/state/omarchy/settings/weather.json" ]] || fail "weather location clear removes the state file" +pass "weather location clear removes the state file"