Add deterministic daily weather brief

This commit is contained in:
zv
2026-06-11 20:27:10 +02:00
parent 47a292b26e
commit 49265e7c51
16 changed files with 590 additions and 60 deletions

View File

@@ -36,6 +36,8 @@ const translations = {
"settings.notifications.deviceTitle": "To urządzenie",
"settings.notifications.enable": "Alerty IMGW",
"settings.notifications.enableDescription": "Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.",
"settings.notifications.morningBrief": "Brief poranny o 7:00",
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
"settings.notifications.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.saving": "Zapisuję…",
@@ -62,6 +64,12 @@ const translations = {
"error.title": "Nie udało się pobrać danych",
"error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"brief.label": "Brief dnia",
"brief.description": "Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.",
"brief.pushHint": "Krótka wersja może być wysłana jako poranne powiadomienie po włączeniu jej w ustawieniach.",
"brief.error": "Nie udało się przygotować briefu dnia.",
"brief.emptyTitle": "Brak briefu",
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
"location.label": "Twoja lokalizacja",
"location.searchLabel": "Szukaj miejscowości w Polsce",
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
@@ -259,6 +267,8 @@ const translations = {
"settings.notifications.deviceTitle": "This device",
"settings.notifications.enable": "IMGW alerts",
"settings.notifications.enableDescription": "The subscription will be saved for the selected province and used by the server warning checker.",
"settings.notifications.morningBrief": "Morning brief at 7:00",
"settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.",
"settings.notifications.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device",
"settings.notifications.saving": "Saving…",
@@ -285,6 +295,12 @@ const translations = {
"error.title": "Unable to load data",
"error.description": "Check your connection and try again.",
"dashboard.error": "Unable to load the IMGW synoptic station list.",
"brief.label": "Daily brief",
"brief.description": "Automatic summary for: {location}. No external AI model, only forecast data and warnings.",
"brief.pushHint": "A short version can be sent as a morning notification after enabling it in settings.",
"brief.error": "Unable to prepare the daily brief.",
"brief.emptyTitle": "Brief unavailable",
"brief.emptyDescription": "The forecast does not contain enough data for the next day.",
"location.label": "Your location",
"location.searchLabel": "Search places in Poland",
"location.placeholder": "Enter a place, e.g. Piaseczno…",

View File

@@ -12,7 +12,14 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
return response.json() as Promise<VapidKeyResponse>;
}
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language) {
interface SavePushSubscriptionOptions {
morningBriefEnabled?: boolean;
latitude?: number | null;
longitude?: number | null;
locationName?: string | null;
}
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) {
const response = await fetch("/api/notifications/subscriptions", {
method: "POST",
headers: { "content-type": "application/json" },
@@ -21,6 +28,10 @@ export async function savePushSubscription(subscription: PushSubscription, provi
province,
language,
enabled: true,
morningBriefEnabled: options.morningBriefEnabled === true,
latitude: options.latitude ?? null,
longitude: options.longitude ?? null,
locationName: options.locationName ?? null,
}),
});
if (!response.ok) throw new Error("Unable to save push subscription.");

View File

@@ -3,6 +3,7 @@ import type { Language } from "@/lib/i18n";
import { formatProvinceName } from "@/lib/provinces";
import type { WeatherWarning } from "@/types/imgw";
import type { WarningPushSubscription } from "@/types/notifications";
import type { WeatherBrief } from "@/lib/weather-brief";
const VAPID_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY;
const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_KEY;
@@ -82,3 +83,13 @@ export async function sendTestNotification(preference: WarningPushSubscription)
});
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
}
export async function sendMorningBriefNotification(preference: WarningPushSubscription, brief: WeatherBrief) {
ensureWebPushConfigured();
const payload = JSON.stringify({
title: preference.language === "pl" ? "wtr.: brief dnia" : "wtr.: daily brief",
body: brief.pushBody,
url: "/",
});
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
}

View File

@@ -3,6 +3,7 @@ import type { WarningPushSubscription } from "@/types/notifications";
interface PushStore {
subscriptions: Map<string, WarningPushSubscription>;
sentWarningIds: Map<string, Set<string>>;
sentMorningBriefDates: Map<string, Set<string>>;
}
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore };
@@ -12,6 +13,7 @@ function getStore() {
globalStore.__wtrPushStore = {
subscriptions: new Map(),
sentWarningIds: new Map(),
sentMorningBriefDates: new Map(),
};
}
return globalStore.__wtrPushStore;
@@ -25,6 +27,7 @@ export function removePushSubscription(endpoint: string) {
const store = getStore();
store.subscriptions.delete(endpoint);
store.sentWarningIds.delete(endpoint);
store.sentMorningBriefDates.delete(endpoint);
}
export function getPushSubscriptions() {
@@ -53,3 +56,14 @@ export function pruneSentWarnings(activeWarningIds: Set<string>) {
});
});
}
export function hasSentMorningBrief(endpoint: string, dateKey: string) {
return getStore().sentMorningBriefDates.get(endpoint)?.has(dateKey) ?? false;
}
export function markMorningBriefSent(endpoint: string, dateKey: string) {
const store = getStore();
const sentDates = store.sentMorningBriefDates.get(endpoint) ?? new Set<string>();
sentDates.add(dateKey);
store.sentMorningBriefDates.set(endpoint, sentDates);
}

52
lib/server-forecast.ts Normal file
View File

@@ -0,0 +1,52 @@
import { mergeForecastSources } from "@/lib/forecast-merge";
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
// This browser token is published by the official meteo.imgw.pl frontend.
const IMGW_FORECAST_TOKEN = "p4DXKjsYadfBV21TYrDk";
const OPEN_METEO_TIMEOUT_MS = 12_000;
const IMGW_TIMEOUT_MS = 5_000;
export function parseForecastCoordinate(value: string | null, min: number, max: number) {
if (!value?.trim()) return null;
const coordinate = Number(value);
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
}
async function readImgwPayload(response: Response | null) {
if (!response?.ok) return null;
try {
return await response.json() as RawImgwForecastResponse;
} catch {
return null;
}
}
export async function fetchServerForecast(latitude: number, longitude: number) {
const openMeteoParams = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
timezone: "Europe/Warsaw",
forecast_days: "7",
wind_speed_unit: "ms",
});
const imgwParams = new URLSearchParams({
token: IMGW_FORECAST_TOKEN,
lat: String(latitude),
lon: String(longitude),
m: "alaro",
});
const [openMeteoResponse, imgwResponse] = await Promise.all([
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null),
]);
if (!openMeteoResponse.ok) throw new Error("Forecast service is unavailable.");
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
const imgwPayload = await readImgwPayload(imgwResponse);
return mergeForecastSources(openMeteoPayload, imgwPayload);
}

View File

@@ -12,12 +12,14 @@ interface WeatherStore {
selectedStationId: string | null;
selectedLocation: SelectedLocation | null;
warningNotificationsEnabled: boolean;
morningBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null;
toggleFavorite: (id: string) => void;
selectStation: (id: string) => void;
selectLocation: (location: SelectedLocation) => void;
setWarningNotificationsEnabled: (enabled: boolean) => void;
setMorningBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void;
}
@@ -29,6 +31,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectedStationId: null,
selectedLocation: null,
warningNotificationsEnabled: false,
morningBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected",
warningNotificationProvince: null,
toggleFavorite: (id) =>
@@ -40,6 +43,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }),
setMorningBriefNotificationsEnabled: (enabled) => set({ morningBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
}),

219
lib/weather-brief.ts Normal file
View File

@@ -0,0 +1,219 @@
import type { Language } from "@/lib/i18n";
import { getForecastCondition } from "@/lib/forecast-utils";
import { formatProvinceName } from "@/lib/provinces";
import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherWarning } from "@/types/imgw";
import type { Province } from "@/types/province";
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
export interface WeatherBrief {
headline: string;
body: string[];
pushBody: string;
severity: WeatherBriefSeverity;
generatedAt: string;
validUntil: string | null;
}
interface BuildWeatherBriefInput {
forecast: WeatherForecast;
warnings: WeatherWarning[];
province: Province | null;
locationName: string;
language: Language;
now?: Date;
}
function formatNumber(value: number, language: Language, digits = 0) {
return new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
maximumFractionDigits: digits,
}).format(value);
}
function formatTemperature(value: number, language: Language) {
return `${formatNumber(value, language)}°C`;
}
function formatWind(value: number, language: Language) {
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
}
function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`;
}
function formatHour(value: string) {
return value.slice(11, 16);
}
function getTimestamp(value: string | null) {
if (!value) return null;
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? null : timestamp;
}
function getWarsawHour(date: Date) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
return `${part("year")}-${part("month")}-${part("day")}T${part("hour")}:00`;
}
function parseForecastHour(time: string) {
const match = time.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!match) return null;
const [, year, month, day, hour, minute] = match;
const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute));
return Number.isFinite(timestamp) ? timestamp : null;
}
function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
const currentHour = getWarsawHour(now);
const currentTimestamp = parseForecastHour(currentHour);
const upcoming = hours.filter((hour) => {
const hourTimestamp = parseForecastHour(hour.time);
return hourTimestamp === null || currentTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
});
return upcoming.slice(0, limit);
}
function getAvailableNumbers(values: Array<number | null>) {
return values.filter((value): value is number => value !== null && Number.isFinite(value));
}
function getMinimum(values: Array<number | null>) {
const numbers = getAvailableNumbers(values);
return numbers.length ? Math.min(...numbers) : null;
}
function getMaximum(values: Array<number | null>) {
const numbers = getAvailableNumbers(values);
return numbers.length ? Math.max(...numbers) : null;
}
function getTotal(values: Array<number | null>) {
const numbers = getAvailableNumbers(values);
return numbers.length ? numbers.reduce((total, value) => total + value, 0) : null;
}
function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) => number | null) {
return hours.reduce<HourlyForecast | null>((peak, hour) => {
const value = selector(hour);
const peakValue = peak ? selector(peak) : null;
if (value === null) return peak;
return peakValue === null || value > peakValue ? hour : peak;
}, null);
}
function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, now: number) {
const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo"
&& (!province || warning.provinces.includes(province))
&& (validTo === null || validTo > now);
}
function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
const levelDifference = (b.level ?? 0) - (a.level ?? 0);
if (levelDifference) return levelDifference;
return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0);
}
export function buildWeatherBrief({ forecast, warnings, province, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null;
const relevantWarnings = warnings
.filter((warning) => isRelevantMeteoWarning(warning, province, now.getTime()))
.sort(compareWarnings);
const topWarning = relevantWarnings[0] ?? null;
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
? `${formatTemperature(minimumTemperature, language)}-${formatTemperature(maximumTemperature, language)}`
: null;
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
const hasWindSignal = (maximumWind ?? 0) >= 8;
const severity: WeatherBriefSeverity = topWarning ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning
? language === "pl"
? `IMGW: ${topWarning.title || "ostrzeżenie meteorologiczne"}`
: `IMGW: ${topWarning.title || "weather warning"}`
: hasRainSignal
? language === "pl"
? "Dziś warto śledzić opady"
: "Rain is worth watching today"
: hasWindSignal
? language === "pl"
? "Dziś wyraźniejszy wiatr"
: "Wind stands out today"
: language === "pl"
? "Spokojny przebieg najbliższej doby"
: "A calm next 24 hours";
const body: string[] = [];
if (topWarning) {
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
const probability = topWarning.probability !== null
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
: "";
body.push(language === "pl"
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
}
if (temperatureRange) {
body.push(language === "pl"
? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.`
: `Temperature over the next 24 hours: ${temperatureRange}.`);
}
if (rainfallTotal !== null || maximumProbability !== null) {
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
: "";
body.push(language === "pl"
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
: `Rainfall: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
}
if (maximumWind !== null) {
body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWind(maximumWind, language)}.`
: `Wind up to ${formatWind(maximumWind, language)}.`);
}
if (conditionPeakHour) {
body.push(language === "pl"
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`);
}
const pushParts = [
`${locationName}:`,
temperatureRange,
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWind(maximumWind, language)}` : `wind ${formatWind(maximumWind, language)}`) : null,
].filter(Boolean);
const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
: "";
return {
headline,
body,
pushBody: `${warningPrefix}${pushParts.join(", ")}.`,
severity,
generatedAt: now.toISOString(),
validUntil: hours.at(-1)?.time ?? null,
};
}