Show current weather on the Overview

Add a weather card to the Web App Overview, wired like the OpenSky live-map
overlay and backed by the OpenWeather plugin.

- API Server: GET /api/integrations/openweather/current resolves the
  caller's cascade, gates it (master/org/personal opt-in/key), and returns
  trimmed current conditions for the configured or a supplied ?lat=&lon=
  point; off/keyless returns {unavailable, detail} so the card degrades.
- BFF relay (forwards the location override) + route; api.js client.
- Dashboard: a Weather card stacked above Schedule showing an emoji
  condition, temperature in the resolved unit, description, location, and a
  feels-like/wind/humidity/cloud grid. Location follows the same cascade as
  the map (drone -> phone -> browser -> default) without prompting for geo;
  refreshes every 10 min while on Overview.
- validLatLon test; rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 16:34:01 +02:00
co-authored by Claude Opus 4.8
parent 522fe450eb
commit 5960fb4806
11 changed files with 348 additions and 25 deletions
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
)
@@ -426,3 +427,126 @@ func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
// owWeather is the trimmed current-conditions shape the Overview weather card needs,
// flattened out of OpenWeather's richer /data/2.5/weather payload.
type owWeather struct {
Location string `json:"location"`
Country string `json:"country"`
Temp *float64 `json:"temp"`
FeelsLike *float64 `json:"feelsLike"`
Description string `json:"description"`
Icon string `json:"icon"` // OpenWeather icon code, e.g. "01d"
Humidity *int `json:"humidity"`
WindSpeed *float64 `json:"windSpeed"`
WindDeg *int `json:"windDeg"`
Clouds *int `json:"clouds"`
Dt int64 `json:"dt"` // observation time (unix seconds)
}
// validLatLon reports whether lat/lon are well-formed geographic coordinates.
func validLatLon(lat, lon string) bool {
la, e1 := strconv.ParseFloat(lat, 64)
lo, e2 := strconv.ParseFloat(lon, 64)
return e1 == nil && e2 == nil && la >= -90 && la <= 90 && lo >= -180 && lo <= 180
}
// GET /api/integrations/openweather/current — current conditions for the caller's
// resolved location (or a supplied ?lat=&lon= point), for the Overview weather card.
// Runs server-side against the resolved cascade config (never returns the API key).
// Gated by the same switches as the settings view: global master, org gate, and the
// caller's personal opt-in. When any gate is off (or no key resolves) it returns 200
// with {unavailable:true, detail} so the card can degrade quietly rather than error.
func (s *Server) handleOpenWeatherCurrent(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveOpenWeather(r.Context(), who, userRaw)
units := res.eff.Units
if units == "" {
units = "metric" // matches the plugin's runtime fallback
}
unavailable := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"unavailable": true, "detail": detail, "units": units})
}
switch {
case !res.available:
unavailable("OpenWeather is disabled by the administrator")
return
case !res.orgEnabled:
unavailable("OpenWeather is disabled for your organization")
return
case !res.enabled:
unavailable("Enable OpenWeather in Settings → Integrations to show weather")
return
case strings.TrimSpace(res.eff.APIKey) == "":
unavailable("No API key configured for OpenWeather")
return
}
// Optional point override (drone/device/browser location the Overview resolves).
// Malformed input is ignored so the plugin falls back to the configured default.
var payload json.RawMessage
lat := strings.TrimSpace(r.URL.Query().Get("lat"))
lon := strings.TrimSpace(r.URL.Query().Get("lon"))
if validLatLon(lat, lon) {
payload, _ = json.Marshal(map[string]string{"lat": lat, "lon": lon})
}
cfg := map[string]string{}
for _, k := range owFields {
cfg[k] = owGet(res.eff, k)
}
raw, err := s.plugins.InvokeWith(r.Context(), openWeatherPlugin, cfg, "weather.current", payload)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// Flatten OpenWeather's /data/2.5/weather response into the card model.
var owResp struct {
Weather []struct {
Description string `json:"description"`
Icon string `json:"icon"`
} `json:"weather"`
Main struct {
Temp *float64 `json:"temp"`
FeelsLike *float64 `json:"feels_like"`
Humidity *int `json:"humidity"`
} `json:"main"`
Wind struct {
Speed *float64 `json:"speed"`
Deg *int `json:"deg"`
} `json:"wind"`
Clouds struct {
All *int `json:"all"`
} `json:"clouds"`
Dt int64 `json:"dt"`
Name string `json:"name"`
Sys struct {
Country string `json:"country"`
} `json:"sys"`
}
if err := json.Unmarshal(raw, &owResp); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenWeather response"})
return
}
weather := owWeather{
Location: owResp.Name,
Country: owResp.Sys.Country,
Temp: owResp.Main.Temp,
FeelsLike: owResp.Main.FeelsLike,
Humidity: owResp.Main.Humidity,
WindSpeed: owResp.Wind.Speed,
WindDeg: owResp.Wind.Deg,
Clouds: owResp.Clouds.All,
Dt: owResp.Dt,
}
if len(owResp.Weather) > 0 {
weather.Description = owResp.Weather[0].Description
weather.Icon = owResp.Weather[0].Icon
}
writeJSON(w, http.StatusOK, map[string]any{"weather": weather, "units": units})
}
@@ -98,6 +98,21 @@ func TestOpenWeatherViewMasksKey(t *testing.T) {
}
}
func TestValidLatLon(t *testing.T) {
ok := [][2]string{{"0", "0"}, {"52.2297", "21.0122"}, {"-90", "180"}, {"90", "-180"}}
for _, c := range ok {
if !validLatLon(c[0], c[1]) {
t.Errorf("validLatLon(%q,%q) = false, want true", c[0], c[1])
}
}
bad := [][2]string{{"", ""}, {"91", "0"}, {"0", "181"}, {"-91", "0"}, {"abc", "0"}, {"0", "x"}}
for _, c := range bad {
if validLatLon(c[0], c[1]) {
t.Errorf("validLatLon(%q,%q) = true, want false", c[0], c[1])
}
}
}
// mergeOpenWeather must preserve sibling plugin keys (opensky/webdav) untouched.
func TestMergeOpenWeatherPreservesSiblings(t *testing.T) {
existing := json.RawMessage(`{"opensky":{"enabled":true},"webdav":{"config":{"baseURL":"https://x"}}}`)
+1
View File
@@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/openweather", s.handleGetOpenWeather)
mux.HandleFunc("PUT /api/integrations/openweather", s.handlePutOpenWeather)
mux.HandleFunc("POST /api/integrations/openweather/health", s.handleOpenWeatherHealth)
mux.HandleFunc("GET /api/integrations/openweather/current", s.handleOpenWeatherCurrent)
// User-management — gated on the caller being a manager (admin or superadmin).
// Admins are scoped to their own organization inside each handler.