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

@@ -40,12 +40,13 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji. - Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
- Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`. - Hybrid wybieraj z pierwszego pełnego rekordu analizy zwracanego przez endpoint dla lokalizacji, preferując `Type_Ten_Minutes`, a potem `Type_Hour`. Wymagaj realnych wartości liczbowych; nie traktuj `null` jako pełnego pola i nie opieraj wyboru na zegarze przeglądarki. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych. - W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
- Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - Route handler prognozy pobiera pełne 7 dni Open-Meteo oraz godzinowe IMGW ALARO. W godzinach pokrytych przez ALARO parametry IMGW mają pierwszeństwo, Open-Meteo dostarcza prawdopodobieństwo opadu i dalszy horyzont, a awaria ALARO pozostawia działający fallback Open-Meteo. Dashboard pokazuje regułowy brief dnia, najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
- Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych. - Ostrzeżenia hydro zawierają jawne województwa, a ostrzeżenia meteo kody powiatów TERYT. Normalizuj oba warianty przez `lib/provinces.ts`; nie filtruj ostrzeżeń wyłącznie po opisach tekstowych.
- Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych. - Listy ostrzeżeń zachowują priorytet lokalnego województwa, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego województwa. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
- Powiadomienia Web Push o ostrzeżeniach meteo konfiguruj przez `/settings`, `public/sw.js` i route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. - Brief dnia generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj go jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API.
- Powiadomienia Web Push o ostrzeżeniach meteo i porannym briefie konfiguruj przez `/settings`, `public/sw.js` i route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji.
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.

View File

@@ -2,7 +2,7 @@
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.** **Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z czytelną typografią, opaque surfaces i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami. `wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową łączącą IMGW ALARO z Open-Meteo. Aplikacja prezentuje bieżącą analizę pogody IMGW Hybrid, odczyty synoptyczne, prognozę godzinową i 7-dniową, regułowy brief dnia, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z czytelną typografią, opaque surfaces i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone. Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
@@ -71,6 +71,8 @@ Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjaln
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie historię odczytów. Prognoza modelowa jest wyraźnie oddzielona od pomiarów IMGW oraz bieżącej analizy Hybrid. Parametry ALARO mają pierwszeństwo w godzinach objętych tym modelem, natomiast prawdopodobieństwo opadu i dalszy horyzont pochodzą z Open-Meteo, ponieważ ALARO nie publikuje prawdopodobieństwa opadu i nie obejmuje pełnych 7 dni. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
Brief dnia jest deterministycznym podsumowaniem danych, a nie wywołaniem modelu AI. Moduł `lib/weather-brief.ts` analizuje najbliższe 24 godziny prognozy modelowej oraz aktywne i nadchodzące ostrzeżenia meteo IMGW dla województwa użytkownika. Na tej podstawie tworzy dłuższy opis na dashboard oraz krótszą wersję do powiadomienia. Dzięki temu funkcja nie wymaga klucza OpenAI API i działa przewidywalnie na małym serwerze, np. Raspberry Pi.
Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu. Pole `suma_opadu` z endpointu synoptycznego jest prezentowane jako akumulowana suma opadu. Nie służy do wnioskowania, że w danej chwili pada, ani do uruchamiania animacji deszczu.
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące. Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
@@ -129,7 +131,7 @@ public/ manifest, ikony i service worker
Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości. Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.js`. Rejestracja service workera działa w buildzie produkcyjnym. Powłoka aplikacji ma podstawowy offline fallback. Odpowiedzi API mogą być dostępne z pamięci urządzenia przy braku sieci, ale UI nadal prezentuje czas pomiaru i status świeżości.
Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia i wysłanie powiadomienia testowego. Na iOS/iPadOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Na Androidzie i desktopie wystarczy HTTPS oraz przeglądarka obsługująca Web Push; instalacja PWA nie jest wymagana. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji, a `/api/notifications/test` wysyła test na wskazany endpoint subskrypcji. Obecny magazyn subskrypcji działa w pamięci procesu, więc produkcja wymaga podmiany `lib/push-store.ts` na trwałą bazę lub KV. Widok `/settings` zawiera konfigurację powiadomień o ostrzeżeniach meteorologicznych IMGW: wybór województwa, zgodę Web Push, zapis subskrypcji urządzenia, poranny brief i wysłanie powiadomienia testowego. Na iOS/iPadOS powiadomienia webowe wymagają dodania PWA do ekranu początkowego i uruchomienia aplikacji z ikony. Na Androidzie i desktopie wystarczy HTTPS oraz przeglądarka obsługująca Web Push; instalacja PWA nie jest wymagana. Endpoint `/api/notifications/check` sprawdza `warningsmeteo` i wysyła tylko nowe ostrzeżenia meteo do pasujących subskrypcji, `/api/notifications/daily-brief` wysyła raz dziennie krótkie podsumowanie dla subskrypcji z zapisaną lokalizacją, a `/api/notifications/test` wysyła test na wskazany endpoint subskrypcji. Obecny magazyn subskrypcji działa w pamięci procesu, więc produkcja wymaga podmiany `lib/push-store.ts` na trwałą bazę lub KV.
Do wysyłki Web Push wymagane są zmienne środowiskowe: Do wysyłki Web Push wymagane są zmienne środowiskowe:
@@ -140,7 +142,7 @@ WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
``` ```
Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`. Klucze VAPID można wygenerować poleceniem `npx web-push generate-vapid-keys`. Cron ostrzeżeń powinien wywoływać `GET /api/notifications/check` z nagłówkiem `Authorization: Bearer <NOTIFICATIONS_CRON_SECRET>` albo `x-cron-secret`. Drugi cron, ustawiony na około 07:00 czasu polskiego, powinien wywoływać `GET /api/notifications/daily-brief` z tym samym nagłówkiem.
## Wdrożenie na Vercel ## Wdrożenie na Vercel

View File

@@ -1,62 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { mergeForecastSources } from "@/lib/forecast-merge"; import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
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;
function parseCoordinate(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 GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90); const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180); const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
if (latitude === null || longitude === null) { if (latitude === null || longitude === null) {
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 }); return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
} }
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",
});
try { try {
const [openMeteoResponse, imgwResponse] = await Promise.all([ return NextResponse.json(await fetchServerForecast(latitude, longitude), {
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) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
const openMeteoPayload = await openMeteoResponse.json() as RawWeatherForecast;
const imgwPayload = await readImgwPayload(imgwResponse);
return NextResponse.json(mergeForecastSources(openMeteoPayload, imgwPayload), {
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" }, headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
}); });
} catch { } catch {

View File

@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { fetchServerForecast } from "@/lib/server-forecast";
import { fetchMeteoWarnings } from "@/lib/server-warnings";
import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store";
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
import { buildWeatherBrief } from "@/lib/weather-brief";
export const runtime = "nodejs";
function isCronAuthorized(request: Request) {
const secret = process.env.NOTIFICATIONS_CRON_SECRET;
if (!secret) return process.env.NODE_ENV !== "production";
const authHeader = request.headers.get("authorization");
const secretHeader = request.headers.get("x-cron-secret");
return authHeader === `Bearer ${secret}` || secretHeader === secret;
}
function getWarsawDateKey(date = new Date()) {
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 ?? "";
return `${part("year")}-${part("month")}-${part("day")}`;
}
export async function GET(request: Request) {
if (!isCronAuthorized(request)) {
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
}
if (!isWebPushConfigured()) {
return NextResponse.json({ error: "Web Push is not configured." }, { status: 503 });
}
const now = new Date();
const dateKey = getWarsawDateKey(now);
const subscriptions = getPushSubscriptions().filter((subscription) => (
subscription.enabled
&& subscription.morningBriefEnabled
&& Number.isFinite(subscription.latitude)
&& Number.isFinite(subscription.longitude)
));
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000));
let sent = 0;
let skipped = 0;
let failed = 0;
for (const subscription of subscriptions) {
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
skipped += 1;
continue;
}
try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
const brief = buildWeatherBrief({
forecast,
warnings,
province: subscription.province,
locationName: subscription.locationName ?? "wtr.",
language: subscription.language,
now,
});
if (!brief) {
skipped += 1;
continue;
}
await sendMorningBriefNotification(subscription, brief);
markMorningBriefSent(subscription.endpoint, dateKey);
sent += 1;
} catch (error) {
failed += 1;
const statusCode = typeof error === "object" && error !== null && "statusCode" in error ? Number(error.statusCode) : null;
if (statusCode === 404 || statusCode === 410) removePushSubscription(subscription.endpoint);
}
}
return NextResponse.json({
ok: true,
date: dateKey,
subscriptions: subscriptions.length,
sent,
skipped,
failed,
});
}

View File

@@ -26,6 +26,10 @@ export async function POST(request: Request) {
province?: unknown; province?: unknown;
language?: unknown; language?: unknown;
enabled?: unknown; enabled?: unknown;
morningBriefEnabled?: unknown;
latitude?: unknown;
longitude?: unknown;
locationName?: unknown;
} | null; } | null;
const subscription = body?.subscription; const subscription = body?.subscription;
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
@@ -42,6 +46,10 @@ export async function POST(request: Request) {
province, province,
language, language,
enabled: body?.enabled !== false, enabled: body?.enabled !== false,
morningBriefEnabled: body?.morningBriefEnabled === true,
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });

View File

@@ -15,6 +15,7 @@ import { useCurrentWeather } from "@/hooks/use-current-weather";
import { ForecastPanel } from "@/components/forecast/forecast-panel"; import { ForecastPanel } from "@/components/forecast/forecast-panel";
import { locateSynopStations } from "@/lib/location-utils"; import { locateSynopStations } from "@/lib/location-utils";
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
export function DashboardPage() { export function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
@@ -43,6 +44,7 @@ export function DashboardPage() {
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} /> <WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<DashboardWarnings /> <DashboardWarnings />
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} /> <ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
<FavoritesSection stations={stations} /> <FavoritesSection stations={stations} />
<FeaturedStationsSection stations={stations} /> <FeaturedStationsSection stations={stations} />

View File

@@ -0,0 +1,81 @@
"use client";
import { useMemo } from "react";
import { AlertTriangle, BellRing, CloudSun, RefreshCw } from "lucide-react";
import { EmptyState } from "@/components/states/empty-state";
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useForecast } from "@/hooks/use-forecast";
import { useWarnings } from "@/hooks/use-warnings";
import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n";
import { getProvinceForSelection } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store";
import { buildWeatherBrief } from "@/lib/weather-brief";
export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
const { language, t } = useI18n();
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
const { data: warnings = [] } = useWarnings();
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const brief = useMemo(() => {
if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings, province, locationName, language });
}, [forecast, language, locationName, province, warnings]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
return <LoadingSkeleton className="h-48" />;
}
if (isError || !forecast) {
return (
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-muted">{t("brief.error")}</p>
<Button variant="glass" className="mt-4" onClick={() => refetch()}>
<RefreshCw className="size-4" />
{t("common.retry")}
</Button>
</Card>
);
}
if (!brief) {
return <EmptyState title={t("brief.emptyTitle")} description={t("brief.emptyDescription")} />;
}
const isWarning = brief.severity === "warning";
return (
<Card className="p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className={isWarning ? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning" : "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"}>
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />}
</div>
<div className="min-w-0">
<p className={isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"}>
{t("brief.label")}
</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
<p className="mt-1 text-sm leading-6 text-muted">{t("brief.description", { location: locationName })}</p>
</div>
</div>
<ul className="mt-4 space-y-2">
{brief.body.slice(0, 4).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
{line}
</li>
))}
</ul>
<p className="mt-4 flex items-center gap-2 text-xs leading-5 text-muted">
<BellRing className="size-3.5 text-accent" aria-hidden="true" />
{t("brief.pushHint")}
</p>
</Card>
);
}

View File

@@ -1,13 +1,16 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Bell, BellRing, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react"; import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { LanguageToggle } from "@/components/ui/language-toggle"; import { LanguageToggle } from "@/components/ui/language-toggle";
import { ThemeToggle } from "@/components/ui/theme-toggle"; import { ThemeToggle } from "@/components/ui/theme-toggle";
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useWeatherStations } from "@/hooks/use-weather-stations";
import { DEFAULT_STATION_ID } from "@/lib/constants"; import { DEFAULT_STATION_ID } from "@/lib/constants";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { locateSynopStations } from "@/lib/location-utils";
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api"; import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces"; import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
@@ -60,15 +63,29 @@ export function SettingsPage() {
const [isSavingSubscription, setIsSavingSubscription] = useState(false); const [isSavingSubscription, setIsSavingSubscription] = useState(false);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
const [notificationMessage, setNotificationMessage] = useState<string | null>(null); const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
const { data: stations } = useWeatherStations();
const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled); const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled);
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
const selectedStation = stations?.find((station) => station.id === selectedStationId)
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
?? stations?.[0];
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null;
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince; const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince"); const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
@@ -99,14 +116,19 @@ export function SettingsPage() {
const registration = await navigator.serviceWorker?.getRegistration(); const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription(); const subscription = await registration?.pushManager.getSubscription();
if (!subscription || cancelled) return; if (!subscription || cancelled) return;
await savePushSubscription(subscription, province, language).catch(() => undefined); await savePushSubscription(subscription, province, language, {
morningBriefEnabled,
latitude: notificationLatitude,
longitude: notificationLongitude,
locationName: notificationLocationName,
}).catch(() => undefined);
} }
syncSubscriptionProvince(); syncSubscriptionProvince();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]); }, [effectiveProvince, language, morningBriefEnabled, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]);
const notificationStatusLabel = useMemo(() => { const notificationStatusLabel = useMemo(() => {
switch (notificationStatus) { switch (notificationStatus) {
@@ -158,7 +180,12 @@ export function SettingsPage() {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey), applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
}); });
await savePushSubscription(subscription, effectiveProvince, language); await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled,
latitude: notificationLatitude,
longitude: notificationLongitude,
locationName: notificationLocationName,
});
setNotificationsEnabled(true); setNotificationsEnabled(true);
setNotificationMessage(t("settings.notifications.saveSuccess")); setNotificationMessage(t("settings.notifications.saveSuccess"));
} catch { } catch {
@@ -180,6 +207,7 @@ export function SettingsPage() {
await subscription.unsubscribe(); await subscription.unsubscribe();
} }
setNotificationsEnabled(false); setNotificationsEnabled(false);
setMorningBriefEnabled(false);
setNotificationMessage(t("settings.notifications.disableSuccess")); setNotificationMessage(t("settings.notifications.disableSuccess"));
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -204,6 +232,24 @@ export function SettingsPage() {
} }
} }
async function updateMorningBriefPreference(enabled: boolean) {
setMorningBriefEnabled(enabled);
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
try {
const registration = await navigator.serviceWorker?.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return;
await savePushSubscription(subscription, effectiveProvince, language, {
morningBriefEnabled: enabled,
latitude: notificationLatitude,
longitude: notificationLongitude,
locationName: notificationLocationName,
});
} catch {
setNotificationMessage(t("settings.notifications.saveError"));
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<section> <section>
@@ -301,6 +347,23 @@ export function SettingsPage() {
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>} {notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
</div> </div>
<label className="mt-3 flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
<input
type="checkbox"
checked={morningBriefEnabled}
disabled={!notificationsEnabled}
onChange={(event) => updateMorningBriefPreference(event.target.checked)}
className="mt-1 accent-accent"
/>
<span>
<span className="flex items-center gap-2 font-medium">
<Clock3 className="size-3.5 text-accent" aria-hidden="true" />
{t("settings.notifications.morningBrief")}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.morningBriefDescription")}</span>
</span>
</label>
<div className="mt-4 flex flex-col gap-2 sm:flex-row"> <div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}> <Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
<Bell className="size-4" /> <Bell className="size-4" />

View File

@@ -36,6 +36,8 @@ const translations = {
"settings.notifications.deviceTitle": "To urządzenie", "settings.notifications.deviceTitle": "To urządzenie",
"settings.notifications.enable": "Alerty IMGW", "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.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.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu", "settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.saving": "Zapisuję…", "settings.notifications.saving": "Zapisuję…",
@@ -62,6 +64,12 @@ const translations = {
"error.title": "Nie udało się pobrać danych", "error.title": "Nie udało się pobrać danych",
"error.description": "Sprawdź połączenie i spróbuj ponownie.", "error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.", "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.label": "Twoja lokalizacja",
"location.searchLabel": "Szukaj miejscowości w Polsce", "location.searchLabel": "Szukaj miejscowości w Polsce",
"location.placeholder": "Wpisz miejscowość, np. Piaseczno…", "location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
@@ -259,6 +267,8 @@ const translations = {
"settings.notifications.deviceTitle": "This device", "settings.notifications.deviceTitle": "This device",
"settings.notifications.enable": "IMGW alerts", "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.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.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device", "settings.notifications.disable": "Disable on this device",
"settings.notifications.saving": "Saving…", "settings.notifications.saving": "Saving…",
@@ -285,6 +295,12 @@ const translations = {
"error.title": "Unable to load data", "error.title": "Unable to load data",
"error.description": "Check your connection and try again.", "error.description": "Check your connection and try again.",
"dashboard.error": "Unable to load the IMGW synoptic station list.", "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.label": "Your location",
"location.searchLabel": "Search places in Poland", "location.searchLabel": "Search places in Poland",
"location.placeholder": "Enter a place, e.g. Piaseczno…", "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>; 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", { const response = await fetch("/api/notifications/subscriptions", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -21,6 +28,10 @@ export async function savePushSubscription(subscription: PushSubscription, provi
province, province,
language, language,
enabled: true, 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."); 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 { formatProvinceName } from "@/lib/provinces";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
import type { WarningPushSubscription } from "@/types/notifications"; 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_PUBLIC_KEY = process.env.WEB_PUSH_VAPID_PUBLIC_KEY;
const VAPID_PRIVATE_KEY = process.env.WEB_PUSH_VAPID_PRIVATE_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); 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 { interface PushStore {
subscriptions: Map<string, WarningPushSubscription>; subscriptions: Map<string, WarningPushSubscription>;
sentWarningIds: Map<string, Set<string>>; sentWarningIds: Map<string, Set<string>>;
sentMorningBriefDates: Map<string, Set<string>>;
} }
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore }; const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore };
@@ -12,6 +13,7 @@ function getStore() {
globalStore.__wtrPushStore = { globalStore.__wtrPushStore = {
subscriptions: new Map(), subscriptions: new Map(),
sentWarningIds: new Map(), sentWarningIds: new Map(),
sentMorningBriefDates: new Map(),
}; };
} }
return globalStore.__wtrPushStore; return globalStore.__wtrPushStore;
@@ -25,6 +27,7 @@ export function removePushSubscription(endpoint: string) {
const store = getStore(); const store = getStore();
store.subscriptions.delete(endpoint); store.subscriptions.delete(endpoint);
store.sentWarningIds.delete(endpoint); store.sentWarningIds.delete(endpoint);
store.sentMorningBriefDates.delete(endpoint);
} }
export function getPushSubscriptions() { 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; selectedStationId: string | null;
selectedLocation: SelectedLocation | null; selectedLocation: SelectedLocation | null;
warningNotificationsEnabled: boolean; warningNotificationsEnabled: boolean;
morningBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode; warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null; warningNotificationProvince: Province | null;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
selectStation: (id: string) => void; selectStation: (id: string) => void;
selectLocation: (location: SelectedLocation) => void; selectLocation: (location: SelectedLocation) => void;
setWarningNotificationsEnabled: (enabled: boolean) => void; setWarningNotificationsEnabled: (enabled: boolean) => void;
setMorningBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void; setWarningNotificationProvince: (province: Province | null) => void;
} }
@@ -29,6 +31,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectedStationId: null, selectedStationId: null,
selectedLocation: null, selectedLocation: null,
warningNotificationsEnabled: false, warningNotificationsEnabled: false,
morningBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected", warningNotificationProvinceMode: "selected",
warningNotificationProvince: null, warningNotificationProvince: null,
toggleFavorite: (id) => toggleFavorite: (id) =>
@@ -40,6 +43,7 @@ export const useWeatherStore = create<WeatherStore>()(
selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }), selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }), selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }), setWarningNotificationsEnabled: (enabled) => set({ warningNotificationsEnabled: enabled }),
setMorningBriefNotificationsEnabled: (enabled) => set({ morningBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), 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,
};
}

View File

@@ -18,6 +18,10 @@ export interface WarningPushSubscription {
province: Province; province: Province;
language: Language; language: Language;
enabled: boolean; enabled: boolean;
morningBriefEnabled: boolean;
latitude: number | null;
longitude: number | null;
locationName: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }