feat(weather): live current conditions endpoint + polling display in briefing view

This commit is contained in:
2026-04-08 21:33:27 -04:00
parent 304affb837
commit 8647f52fbc
3 changed files with 147 additions and 2 deletions
+61 -2
View File
@@ -60,6 +60,23 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const weatherData = ref<WeatherData[]>([])
const tempUnit = ref<string>('C')
interface CurrentConditions {
temperature: number | null;
windspeed: number | null;
description: string;
precip_next_3h: number[];
temp_unit: string;
location: string;
}
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
} catch { /* silent — endpoint may not have locations configured */ }
}
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
@@ -90,6 +107,7 @@ async function loadAll() {
getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
loadCurrentConditions(),
])
conversations.value = convList
if (today) {
@@ -198,11 +216,18 @@ useBackgroundRefresh(
)
let _mounted = true
onUnmounted(() => { _mounted = false })
onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
if (!showWizard.value) {
await loadAll()
// Poll current conditions every 30 minutes
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
}
})
</script>
@@ -235,6 +260,15 @@ onMounted(async () => {
<!-- Left column: Weather -->
<div class="briefing-left">
<div class="panel-label">Weather</div>
<!-- Current conditions (live) -->
<div v-if="currentConditions && currentConditions.temperature != null" class="current-conditions">
<div class="cc-temp">{{ currentConditions.temperature }}°{{ currentConditions.temp_unit }}</div>
<div class="cc-desc">{{ currentConditions.description }}</div>
<div v-if="currentConditions.precip_next_3h?.some((p: number) => p > 0)" class="cc-precip">
Rain next 3h: {{ currentConditions.precip_next_3h.map((p: number) => `${p}%`).join(', ') }}
</div>
</div>
<!-- Forecast card -->
<template v-if="weatherData.length">
<WeatherCard
v-for="loc in weatherData"
@@ -457,6 +491,31 @@ onMounted(async () => {
color: var(--color-text-muted);
}
/* ── Current conditions (live) ─────────────────────────── */
.current-conditions {
padding: 12px 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: 10px;
}
.cc-temp {
font-size: 1.8rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.1;
}
.cc-desc {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-top: 2px;
}
.cc-precip {
font-size: 0.78rem;
color: var(--color-accent-warm);
margin-top: 6px;
}
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
+38
View File
@@ -155,6 +155,44 @@ async def get_weather():
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
+48
View File
@@ -184,6 +184,54 @@ async def geocode(query: str) -> tuple[float, float, str]:
return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
"""Fetch current temperature + conditions + next 3 hours precipitation.
Lightweight call — no daily forecast, no caching needed.
Returns dict with: temperature, windspeed, weathercode, description,
and precip_next_3h (list of hourly precip probabilities).
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"current_weather": "true",
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
current = data.get("current_weather", {})
hourly = data.get("hourly", {})
hourly_times = hourly.get("time", [])
hourly_precip = hourly.get("precipitation_probability", [])
# Find the current hour index and get next 3 hours of precip
current_time = current.get("time", "")
precip_next_3h = []
try:
idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
precip_next_3h = hourly_precip[idx:idx + 3]
except (StopIteration, IndexError):
pass
code = current.get("weathercode", 0)
return {
"temperature": current.get("temperature"),
"windspeed": current.get("windspeed"),
"weathercode": code,
"description": describe_weathercode(code),
"time": current_time,
"precip_next_3h": precip_next_3h,
}
except Exception:
logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
return None
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client: