feat: add tomorrow weather brief notifications

This commit is contained in:
zv
2026-06-12 22:10:22 +02:00
parent 3309e03acb
commit 7c3706c3f6
15 changed files with 414 additions and 15 deletions

View File

@@ -38,6 +38,8 @@ const translations = {
"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.tomorrowBrief": "Brief na jutro o 18:00",
"settings.notifications.tomorrowBriefDescription": "Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
"settings.notifications.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.saving": "Zapisuję…",
@@ -66,7 +68,8 @@ const translations = {
"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.tomorrowLabel": "Jutro",
"brief.pushHint": "Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich 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.",
@@ -269,6 +272,8 @@ const translations = {
"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.tomorrowBrief": "Tomorrow brief at 18:00",
"settings.notifications.tomorrowBriefDescription": "Sends a short evening forecast for the next day, including storm and rain signals from the model.",
"settings.notifications.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device",
"settings.notifications.saving": "Saving…",
@@ -297,7 +302,8 @@ const translations = {
"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.tomorrowLabel": "Tomorrow",
"brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them 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.",

View File

@@ -14,6 +14,7 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
interface SavePushSubscriptionOptions {
morningBriefEnabled?: boolean;
tomorrowBriefEnabled?: boolean;
latitude?: number | null;
longitude?: number | null;
locationName?: string | null;
@@ -30,6 +31,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi
language,
enabled: true,
morningBriefEnabled: options.morningBriefEnabled === true,
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
latitude: options.latitude ?? null,
longitude: options.longitude ?? null,
locationName: options.locationName ?? null,

View File

@@ -93,3 +93,13 @@ export async function sendMorningBriefNotification(preference: WarningPushSubscr
});
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
}
export async function sendTomorrowBriefNotification(preference: WarningPushSubscription, brief: WeatherBrief) {
ensureWebPushConfigured();
const payload = JSON.stringify({
title: preference.language === "pl" ? "wtr.: jutro" : "wtr.: tomorrow",
body: brief.pushBody,
url: "/",
});
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
}

View File

@@ -4,6 +4,7 @@ interface PushStore {
subscriptions: Map<string, WarningPushSubscription>;
sentWarningIds: Map<string, Set<string>>;
sentMorningBriefDates: Map<string, Set<string>>;
sentTomorrowBriefDates: Map<string, Set<string>>;
}
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore };
@@ -14,6 +15,7 @@ function getStore() {
subscriptions: new Map(),
sentWarningIds: new Map(),
sentMorningBriefDates: new Map(),
sentTomorrowBriefDates: new Map(),
};
}
return globalStore.__wtrPushStore;
@@ -28,6 +30,7 @@ export function removePushSubscription(endpoint: string) {
store.subscriptions.delete(endpoint);
store.sentWarningIds.delete(endpoint);
store.sentMorningBriefDates.delete(endpoint);
store.sentTomorrowBriefDates.delete(endpoint);
}
export function getPushSubscriptions() {
@@ -67,3 +70,14 @@ export function markMorningBriefSent(endpoint: string, dateKey: string) {
sentDates.add(dateKey);
store.sentMorningBriefDates.set(endpoint, sentDates);
}
export function hasSentTomorrowBrief(endpoint: string, dateKey: string) {
return getStore().sentTomorrowBriefDates.get(endpoint)?.has(dateKey) ?? false;
}
export function markTomorrowBriefSent(endpoint: string, dateKey: string) {
const store = getStore();
const sentDates = store.sentTomorrowBriefDates.get(endpoint) ?? new Set<string>();
sentDates.add(dateKey);
store.sentTomorrowBriefDates.set(endpoint, sentDates);
}

View File

@@ -13,6 +13,7 @@ interface WeatherStore {
selectedLocation: SelectedLocation | null;
warningNotificationsEnabled: boolean;
morningBriefNotificationsEnabled: boolean;
tomorrowBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null;
toggleFavorite: (id: string) => void;
@@ -20,6 +21,7 @@ interface WeatherStore {
selectLocation: (location: SelectedLocation) => void;
setWarningNotificationsEnabled: (enabled: boolean) => void;
setMorningBriefNotificationsEnabled: (enabled: boolean) => void;
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void;
}
@@ -32,6 +34,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectedLocation: null,
warningNotificationsEnabled: false,
morningBriefNotificationsEnabled: false,
tomorrowBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected",
warningNotificationProvince: null,
toggleFavorite: (id) =>
@@ -44,6 +47,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }),
setMorningBriefNotificationsEnabled: (enabled) => set({ morningBriefNotificationsEnabled: enabled }),
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
}),

View File

@@ -41,6 +41,10 @@ function formatWind(value: number, language: Language) {
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
}
function formatWindKmh(value: number, language: Language) {
return `${formatNumber(value * 3.6, language)} km/h`;
}
function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`;
}
@@ -68,6 +72,27 @@ function getWarsawHour(date: Date) {
return `${part("year")}-${part("month")}-${part("day")}T${part("hour")}:00`;
}
function getWarsawDateKey(date: Date, dayOffset = 0) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
}
function parseForecastHour(time: string) {
const match = time.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!match) return null;
@@ -86,6 +111,10 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
return upcoming.slice(0, limit);
}
function getForecastHoursForDate(hours: HourlyForecast[], dateKey: string) {
return hours.filter((hour) => hour.time.startsWith(`${dateKey}T`));
}
function getAvailableNumbers(values: Array<number | null>) {
return values.filter((value): value is number => value !== null && Number.isFinite(value));
}
@@ -121,12 +150,63 @@ function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | nu
&& (validTo === null || validTo > now);
}
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
const validFromKey = warning.validFrom ? getWarsawDateKey(new Date(warning.validFrom)) : null;
const validToKey = warning.validTo ? getWarsawDateKey(new Date(warning.validTo)) : null;
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
}
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) {
return warning.kind === "meteo"
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
&& warningOverlapsWarsawDate(warning, dateKey);
}
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);
}
function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => boolean) {
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode));
}
function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | null, maximumProbability: number | null, language: Language) {
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
const hasDrizzle = hasWeatherCode(hours, (code) => code >= 51 && code <= 57);
const hasFog = hasWeatherCode(hours, (code) => code === 45 || code === 48);
const hasClouds = hasWeatherCode(hours, (code) => code === 3);
const hasPartialClouds = hasWeatherCode(hours, (code) => code === 1 || code === 2);
const rainfall = rainfallTotal ?? 0;
const probability = maximumProbability ?? 0;
if (hasThunderstorm) {
return language === "pl" ? "burze" : "thunderstorms";
}
if (hasSnow) {
return language === "pl" ? "opady śniegu" : "snow";
}
if (rainfall >= 5 || hasRain) {
return language === "pl" ? "opady deszczu" : "rain";
}
if (rainfall >= 0.5 || probability >= 35 || hasDrizzle) {
return language === "pl" ? "możliwy deszcz" : "possible rain";
}
if (hasFog) {
return language === "pl" ? "mgła" : "fog";
}
if (hasClouds) {
return language === "pl" ? "pochmurno" : "cloudy";
}
if (hasPartialClouds) {
return language === "pl" ? "częściowe zachmurzenie" : "partly cloudy";
}
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
}
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null;
@@ -219,3 +299,109 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
validUntil: hours.at(-1)?.time ?? null,
};
}
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1);
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
if (!hours.length) return null;
const relevantWarnings = warnings
.filter((warning) => isRelevantMeteoWarningForDate(warning, province, countyTeryt, targetDateKey))
.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 hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasRainSignal = hasThunderstorm
|| (rainfallTotal ?? 0) >= 0.5
|| (maximumProbability ?? 0) >= 35
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
const hasWindSignal = (maximumWind ?? 0) >= 8;
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}`
: null;
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning
? language === "pl"
? `Jutro: IMGW ${topWarning.title || "ostrzeżenie meteorologiczne"}`
: `Tomorrow: IMGW ${topWarning.title || "weather warning"}`
: hasThunderstorm
? language === "pl"
? "Jutro możliwe burze"
: "Thunderstorms are possible tomorrow"
: hasRainSignal
? language === "pl"
? "Jutro opady w prognozie"
: "Rain is in tomorrow's forecast"
: hasWindSignal
? language === "pl"
? "Jutro wyraźniejszy wiatr"
: "Wind stands out tomorrow"
: language === "pl"
? "Jutro bez istotnych opadów"
: "No significant precipitation tomorrow";
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 jutro: ${temperatureRange}.`
: `Temperature tomorrow: ${temperatureRange}.`);
}
body.push(language === "pl"
? `Sygnał modelu: ${condition}.`
: `Model signal: ${condition}.`);
if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
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}`
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
} else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
}
if (maximumWind !== null) {
body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWindKmh(maximumWind, language)}.`
: `Wind up to ${formatWindKmh(maximumWind, language)}.`);
}
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
? formatRainfall(rainfallTotal, language)
: null;
const pushParts = [
`${locationName}:`,
temperatureRange,
condition,
rainPushPart,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(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,
};
}