feat(weather): add hourly precipitation summaries and peak timing to weather card

Fetch hourly precipitation probabilities from Open-Meteo alongside daily
forecasts. Generate human-readable precip summaries ("Rain likely 2–5 PM",
"Rain likely all day") for today and each forecast day. Display today's
summary as a styled callout and show peak precipitation hour in forecast rows.
Also fix briefing pipeline to parse all weather location rows (not just first).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 16:05:26 -04:00
parent 443b11f287
commit ddab0db781
4 changed files with 188 additions and 19 deletions
+42 -3
View File
@@ -9,6 +9,8 @@ interface ForecastDay {
precip_probability: number | null
precip_mm: number | null
windspeed_max: number
precip_summary?: string
precip_peak_hour?: string
}
interface WeatherData {
@@ -21,6 +23,7 @@ interface WeatherData {
yesterday_high: number | null
yesterday_low: number | null
wind_unit?: string
precip_summary?: string | null
forecast: ForecastDay[]
}
@@ -68,6 +71,11 @@ const fetchedAtLabel = computed(() => {
return ''
}
})
function hasPrecip(day: ForecastDay): boolean {
return (day.precip_probability != null && day.precip_probability > 0) ||
(day.precip_mm != null && day.precip_mm > 0)
}
</script>
<template>
@@ -85,6 +93,9 @@ const fetchedAtLabel = computed(() => {
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div>
<div v-if="weather.precip_summary" class="weather-precip-summary">
💧 {{ weather.precip_summary }}
</div>
<table class="weather-forecast" v-if="weather.forecast.length">
<thead>
<tr>
@@ -100,8 +111,14 @@ const fetchedAtLabel = computed(() => {
<td class="forecast-day-name">{{ day.day }}</td>
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !(day.precip_probability != null && day.precip_probability > 0) && !(day.precip_mm != null && day.precip_mm > 0) }">
<template v-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
<template v-if="day.precip_summary">
<span class="precip-detail" :title="day.precip_summary">
{{ day.precip_probability }}%
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
</span>
</template>
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
<template v-else>&mdash;</template>
</td>
@@ -168,7 +185,7 @@ const fetchedAtLabel = computed(() => {
.weather-today {
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
margin-bottom: 0.25rem;
font-size: 0.85rem;
}
@@ -177,6 +194,16 @@ const fetchedAtLabel = computed(() => {
font-size: 0.82rem;
}
.weather-precip-summary {
color: var(--color-text-secondary);
font-size: 0.83rem;
margin-bottom: 0.75rem;
padding: 0.35rem 0.5rem;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-radius: var(--radius-sm, 6px);
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
}
.weather-forecast {
width: 100%;
border-collapse: collapse;
@@ -227,6 +254,18 @@ const fetchedAtLabel = computed(() => {
opacity: 0.35;
}
.precip-detail {
display: inline-flex;
align-items: baseline;
gap: 0.3em;
}
.precip-peak {
font-size: 0.7rem;
color: var(--color-text-muted);
opacity: 0.8;
}
.forecast-wind {
text-align: right;
color: var(--color-text-muted);
@@ -379,7 +379,11 @@ async def run_compilation(
if item.get("id")
]
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
weather_cards = [
card for row in weather_rows
if (card := parse_weather_card_data(row, temp_unit)) is not None
]
weather_card = weather_cards[0] if weather_cards else None
briefing_text, agentic_messages = await run_agentic_briefing(
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
+78 -13
View File
@@ -17,6 +17,7 @@ OPEN_METEO_DAILY = (
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
"precipitation_probability_max,weathercode,windspeed_10m_max"
)
OPEN_METEO_HOURLY = "precipitation_probability"
# WMO weather code → description (subset; covers the most common codes)
_WMO_CODES: dict[int, str] = {
@@ -93,6 +94,55 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
return changes
def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
"""Build a human-readable precipitation summary from (hour, probability) pairs.
Returns None when no significant precipitation is expected.
"""
wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
if not wet_hours:
return None
peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
if not daytime_hours:
return None
wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
if len(wet_daytime) >= 10:
return f"Rain likely all day (up to {peak_prob}%)"
if not wet_daytime:
return None
def _fmt_hour(h: int) -> str:
if h == 0 or h == 24:
return "12 AM"
if h == 12:
return "12 PM"
return f"{h} AM" if h < 12 else f"{h - 12} PM"
start = wet_daytime[0]
end = wet_daytime[-1]
if start == end:
return f"{peak_prob}% chance around {_fmt_hour(start)}"
return f"Rain likely {_fmt_hour(start)}{_fmt_hour(end + 1)} (up to {peak_prob}%)"
def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
"""Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
hourly = raw.get("hourly", {})
times = hourly.get("precipitation_probability", [])
time_labels = hourly.get("time", [])
pairs: list[tuple[int, int]] = []
prefix = date_str + "T"
for i, t in enumerate(time_labels):
if t.startswith(prefix) and i < len(times) and times[i] is not None:
hour = int(t[11:13])
pairs.append((hour, times[i]))
return pairs
def parse_weather_card_data(
cache_row,
temp_unit: str = "C",
@@ -138,6 +188,30 @@ def parse_weather_card_data(
wind_unit = "mph" if imperial else "km/h"
today_hourly = _extract_hourly_precip_for_date(raw, today_str)
today_precip_summary = _summarize_precip(today_hourly)
def _forecast_day(d: dict) -> dict:
entry: dict = {
"day": day_label(d["date"]),
"condition": d["description"],
"high": to_temp(d["temp_max"]),
"low": to_temp(d["temp_min"]),
"precip_probability": d["precip_probability"],
"precip_mm": d["precip_mm"],
"windspeed_max": to_wind(d["windspeed_max"]),
}
hourly = _extract_hourly_precip_for_date(raw, d["date"])
summary = _summarize_precip(hourly)
if summary:
entry["precip_summary"] = summary
if hourly:
peak = max(hourly, key=lambda x: x[1])
if peak[1] >= 30:
h = peak[0]
entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
return entry
return {
"location": getattr(cache_row, "location_label", ""),
"fetched_at": cache_row.fetched_at.isoformat(),
@@ -148,18 +222,8 @@ def parse_weather_card_data(
"yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
"yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
"wind_unit": wind_unit,
"forecast": [
{
"day": day_label(d["date"]),
"condition": d["description"],
"high": to_temp(d["temp_max"]),
"low": to_temp(d["temp_min"]),
"precip_probability": d["precip_probability"],
"precip_mm": d["precip_mm"],
"windspeed_max": to_wind(d["windspeed_max"]),
}
for d in future_days
],
"precip_summary": today_precip_summary,
"forecast": [_forecast_day(d) for d in future_days],
}
@@ -259,12 +323,13 @@ async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
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, hourly precip, and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"daily": OPEN_METEO_DAILY,
"hourly": OPEN_METEO_HOURLY,
"current_weather": "true",
"past_days": 1,
"timezone": "auto",
+63 -2
View File
@@ -59,7 +59,6 @@ def test_detect_forecast_changes_rain_added():
def test_parse_weather_card_data_returns_none_when_stale():
"""Should return None when cache is older than 24 hours."""
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock
from fabledassistant.services.weather import parse_weather_card_data
cache = MagicMock()
@@ -71,7 +70,6 @@ def test_parse_weather_card_data_returns_none_when_stale():
def test_parse_weather_card_data_returns_card_schema():
"""Should return the correct metadata.weather schema for fresh cache."""
from datetime import datetime, timezone, timedelta, date
from unittest.mock import MagicMock
from fabledassistant.services.weather import parse_weather_card_data
today = date.today().isoformat()
@@ -103,3 +101,66 @@ def test_parse_weather_card_data_returns_card_schema():
assert card["yesterday_low"] == 9
assert len(card["forecast"]) >= 1
assert card["location"] == "Berlin, DE"
assert card["precip_summary"] is None
def test_summarize_precip_dry():
from fabledassistant.services.weather import _summarize_precip
assert _summarize_precip([(h, 10) for h in range(24)]) is None
def test_summarize_precip_all_day():
from fabledassistant.services.weather import _summarize_precip
result = _summarize_precip([(h, 60) for h in range(24)])
assert result is not None
assert "all day" in result.lower()
def test_summarize_precip_window():
from fabledassistant.services.weather import _summarize_precip
hourly = [(h, 5) for h in range(24)]
for h in range(14, 18):
hourly[h] = (h, 65)
result = _summarize_precip(hourly)
assert result is not None
assert "2 PM" in result
assert "65%" in result
def test_parse_weather_card_includes_hourly_precip_summary():
from datetime import datetime, timezone, timedelta, date
from fabledassistant.services.weather import parse_weather_card_data
today = date.today().isoformat()
yesterday = (date.today() - timedelta(days=1)).isoformat()
tomorrow = (date.today() + timedelta(days=1)).isoformat()
hourly_times = [f"{today}T{h:02d}:00" for h in range(24)]
hourly_probs = [5] * 24
for h in range(14, 18):
hourly_probs[h] = 70
cache = MagicMock()
cache.fetched_at = datetime.now(timezone.utc)
cache.location_label = "Berlin, DE"
cache.forecast_json = {
"current_weather": {"temperature": 12.0, "weathercode": 61},
"daily": {
"time": [yesterday, today, tomorrow],
"temperature_2m_max": [14.0, 16.0, 18.0],
"temperature_2m_min": [9.0, 8.0, 10.0],
"precipitation_sum": [0.0, 5.0, 1.5],
"precipitation_probability_max": [0, 70, 30],
"weathercode": [1, 61, 61],
"windspeed_10m_max": [10.0, 12.0, 8.0],
},
"hourly": {
"time": hourly_times,
"precipitation_probability": hourly_probs,
},
}
card = parse_weather_card_data(cache)
assert card is not None
assert card["precip_summary"] is not None
assert "2 PM" in card["precip_summary"]