feat(weather): live current conditions endpoint + polling display in briefing view
This commit is contained in:
@@ -60,6 +60,23 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
|||||||
const weatherData = ref<WeatherData[]>([])
|
const weatherData = ref<WeatherData[]>([])
|
||||||
const tempUnit = ref<string>('C')
|
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() {
|
async function loadWeather() {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||||
@@ -90,6 +107,7 @@ async function loadAll() {
|
|||||||
getBriefingToday().catch(() => null),
|
getBriefingToday().catch(() => null),
|
||||||
loadWeather(),
|
loadWeather(),
|
||||||
loadNews(),
|
loadNews(),
|
||||||
|
loadCurrentConditions(),
|
||||||
])
|
])
|
||||||
conversations.value = convList
|
conversations.value = convList
|
||||||
if (today) {
|
if (today) {
|
||||||
@@ -198,11 +216,18 @@ useBackgroundRefresh(
|
|||||||
)
|
)
|
||||||
|
|
||||||
let _mounted = true
|
let _mounted = true
|
||||||
onUnmounted(() => { _mounted = false })
|
onUnmounted(() => {
|
||||||
|
_mounted = false
|
||||||
|
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkSetup()
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -235,6 +260,15 @@ onMounted(async () => {
|
|||||||
<!-- Left column: Weather -->
|
<!-- Left column: Weather -->
|
||||||
<div class="briefing-left">
|
<div class="briefing-left">
|
||||||
<div class="panel-label">Weather</div>
|
<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">
|
<template v-if="weatherData.length">
|
||||||
<WeatherCard
|
<WeatherCard
|
||||||
v-for="loc in weatherData"
|
v-for="loc in weatherData"
|
||||||
@@ -457,6 +491,31 @@ onMounted(async () => {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.panel-empty {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -155,6 +155,44 @@ async def get_weather():
|
|||||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
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"])
|
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def geocode_location():
|
async def geocode_location():
|
||||||
|
|||||||
@@ -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)
|
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:
|
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
|||||||
Reference in New Issue
Block a user