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
+14
View File
@@ -310,6 +310,20 @@ export async function testOpenWeather() {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Current conditions for the Overview weather card. lat/lon optionally override the
// configured location (drone/device/browser point). Returns {weather, units} when
// available, or {unavailable:true, detail} when OpenWeather is off for the caller.
export async function getOpenWeatherCurrent(lat, lon) {
try {
const qs = lat != null && lon != null ? `?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}` : ''
const r = await fetch(`/bff/integrations/openweather/current${qs}`)
if (!r.ok) return { unavailable: true, detail: 'Weather unavailable' }
return await r.json()
} catch {
return { unavailable: true, detail: 'Weather unavailable' }
}
}
/* ---------- Logbook: drones ---------- */
export async function getDrones() {
+158 -2
View File
@@ -7,7 +7,7 @@ import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Documents from './Documents.vue'
import Toggle from './settings/Toggle.vue'
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent } from '../api.js'
import { formatTime, prefs } from '../prefs.js'
import { countryForPoint, bboxForCountry } from '../countries.js'
@@ -144,6 +144,94 @@ function stopAirspace() {
airTimer = null
}
/* ---------- OpenWeather current conditions (Overview weather card) ---------- */
// { loaded, unavailable, detail, data: owWeather|null, units, source, updatedAt }
const weather = reactive({ loaded: false, unavailable: false, detail: '', data: null, units: 'metric', source: '', updatedAt: 0 })
let weatherTimer = null
// Weather changes slowly and OpenWeather refreshes roughly every 10 min upstream,
// so poll gently to respect the API quota.
const WEATHER_INTERVAL_MS = 10 * 60 * 1000
// Resolve the point to fetch weather for, reusing the map's location cascade but
// as a single lat/lng: drone GPS → phone GPS → already-granted browser geolocation.
// Returns null when none is known, so the server uses its configured default. Does
// not prompt for geolocation itself (only reuses a point the map already obtained).
function resolveWeatherPoint() {
const drone =
devicePoint(sel.value, 'latitude', 'longitude') ||
ids.value.map((id) => devicePoint(devices[id], 'latitude', 'longitude')).find(Boolean)
if (drone) return { lat: drone.lat, lng: drone.lng, source: 'drone' }
const phone =
devicePoint(sel.value, 'phoneLatitude', 'phoneLongitude') ||
ids.value.map((id) => devicePoint(devices[id], 'phoneLatitude', 'phoneLongitude')).find(Boolean)
if (phone) return { lat: phone.lat, lng: phone.lng, source: 'phone' }
if (browserGeo.value) return { lat: browserGeo.value.lat, lng: browserGeo.value.lng, source: 'browser' }
return null
}
async function refreshWeather() {
const p = resolveWeatherPoint()
const res = await getOpenWeatherCurrent(p ? p.lat : undefined, p ? p.lng : undefined)
weather.loaded = true
weather.units = res.units || 'metric'
if (res.unavailable || !res.weather) {
weather.unavailable = true
weather.detail = res.detail || 'Weather is unavailable.'
weather.data = null
return
}
weather.unavailable = false
weather.detail = ''
weather.data = res.weather
weather.source = p ? p.source : 'default'
weather.updatedAt = Date.now()
}
function scheduleWeather() {
if (weatherTimer) clearInterval(weatherTimer)
weatherTimer = setInterval(() => {
if (active.value === 'Overview') refreshWeather()
}, WEATHER_INTERVAL_MS)
}
function startWeather() {
refreshWeather()
scheduleWeather()
}
function stopWeather() {
if (weatherTimer) clearInterval(weatherTimer)
weatherTimer = null
}
// Temperature/wind units follow the resolved OpenWeather "units" setting.
const tempUnit = computed(() => (weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'))
const windUnit = computed(() => (weather.units === 'imperial' ? 'mph' : 'm/s'))
// Map an OpenWeather icon code (e.g. "01d", "10n") to an emoji, so the card needs
// no external image and works offline.
function wxEmoji(icon) {
const c = (icon || '').slice(0, 2)
if (c === '01') return (icon || '').endsWith('n') ? '🌙' : '☀️'
return { '02': '🌤️', '03': '⛅', '04': '☁️', '09': '🌧️', '10': '🌦️', '11': '⛈️', '13': '❄️', '50': '🌫️' }[c] || '🌡️'
}
const weatherEmoji = computed(() => wxEmoji(weather.data && weather.data.icon))
const weatherLocation = computed(() => {
const d = weather.data
if (!d) return ''
return d.country ? `${d.location}, ${d.country}` : d.location || 'Unknown location'
})
const weatherSourceLabel = computed(() => {
if (weather.source === 'drone') return 'at aircraft location'
if (weather.source === 'phone' || weather.source === 'browser') return 'at your location'
return 'default location'
})
const weatherUpdated = computed(() =>
weather.updatedAt ? new Date(weather.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
)
// Round a nullable numeric field for display, or "—" when absent.
function wxNum(v, digits = 0) {
return typeof v === 'number' ? v.toFixed(digits) : '—'
}
const active = ref('Overview')
const NAV = [
['grid', 'Overview'],
@@ -371,7 +459,10 @@ function track(id) {
// Re-fetch air traffic immediately when the operator returns to the Overview tab,
// so the map isn't stale for up to a poll interval.
watch(active, (v) => {
if (v === 'Overview') refreshAirspace()
if (v === 'Overview') {
refreshAirspace()
refreshWeather()
}
})
// React to the "Show live air traffic" map toggle: fetch at once when enabled,
@@ -392,12 +483,14 @@ onMounted(async () => {
;(await getDevices()).forEach(upsert)
connect()
startAirspace()
startWeather()
})
onBeforeUnmount(() => {
stopped = true
if (retry) clearTimeout(retry)
if (ws) ws.close()
stopAirspace()
stopWeather()
})
</script>
@@ -579,6 +672,68 @@ onBeforeUnmount(() => {
</p>
</div>
<div class="flex flex-col gap-5">
<!-- weather -->
<div class="panel p-5">
<div class="mb-3.5 flex items-center justify-between">
<div>
<div class="eyebrow">Conditions</div>
<div class="mt-0.5 text-base font-semibold text-ink">Weather</div>
</div>
<Icon name="sun" :size="16" class="text-ink-muted" />
</div>
<!-- current conditions -->
<template v-if="weather.data">
<div class="flex items-center gap-3">
<div class="text-5xl leading-none">{{ weatherEmoji }}</div>
<div class="min-w-0">
<div class="flex items-baseline gap-1">
<span class="text-[34px] font-bold leading-none tracking-tightest text-ink">{{ wxNum(weather.data.temp) }}</span>
<span class="text-lg font-semibold text-ink-secondary">{{ tempUnit }}</span>
</div>
<div class="mt-1 truncate text-sm capitalize text-ink-secondary">{{ weather.data.description || '—' }}</div>
</div>
</div>
<div class="mt-1.5 truncate text-xs text-ink-muted">{{ weatherLocation }} · {{ weatherSourceLabel }}</div>
<div class="mt-4 grid grid-cols-2 gap-2.5">
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Feels like</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.feelsLike) }}{{ tempUnit }}</div>
</div>
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Wind</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.windSpeed, 1) }} {{ windUnit }}</div>
</div>
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Humidity</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.humidity) }}<span v-if="weather.data.humidity != null">%</span></div>
</div>
<div class="rounded-lg bg-surface-2 px-3 py-2">
<div class="eyebrow">Cloud cover</div>
<div class="mt-0.5 font-mono text-sm text-ink">{{ wxNum(weather.data.clouds) }}<span v-if="weather.data.clouds != null">%</span></div>
</div>
</div>
<div v-if="weatherUpdated" class="mt-3 text-[11px] text-ink-muted">Updated {{ weatherUpdated }} · OpenWeather</div>
</template>
<!-- unavailable (off / not enabled / no key) -->
<div v-else-if="weather.loaded && weather.unavailable" class="grid place-items-center py-8 text-center">
<Icon name="sun" :size="24" class="text-ink-muted" />
<div class="mt-2 text-sm font-medium text-ink-secondary">Weather unavailable</div>
<div class="mt-0.5 text-xs text-ink-muted">{{ weather.detail }}</div>
</div>
<!-- loading -->
<div v-else class="grid place-items-center py-8 text-center text-sm text-ink-muted">
Loading weather
</div>
</div>
<!-- schedule -->
<div class="panel p-5">
<div class="mb-3.5 flex items-center justify-between">
<div>
@@ -593,6 +748,7 @@ onBeforeUnmount(() => {
<div class="mt-0.5 text-xs text-ink-muted">Scheduling is not wired to a backend yet.</div>
</div>
</div>
</div>
</div>
<!-- fleet table -->