Add deterministic daily weather brief
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -140,7 +142,7 @@ WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
|
||||
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
|
||||
|
||||
|
||||
@@ -1,62 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
||||
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
|
||||
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
|
||||
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
|
||||
if (latitude === null || longitude === null) {
|
||||
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 {
|
||||
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) 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), {
|
||||
return NextResponse.json(await fetchServerForecast(latitude, longitude), {
|
||||
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||
});
|
||||
} catch {
|
||||
|
||||
88
app/api/notifications/daily-brief/route.ts
Normal file
88
app/api/notifications/daily-brief/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export async function POST(request: Request) {
|
||||
province?: unknown;
|
||||
language?: unknown;
|
||||
enabled?: unknown;
|
||||
morningBriefEnabled?: unknown;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
locationName?: unknown;
|
||||
} | null;
|
||||
const subscription = body?.subscription;
|
||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||
@@ -42,6 +46,10 @@ export async function POST(request: Request) {
|
||||
province,
|
||||
language,
|
||||
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,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCurrentWeather } from "@/hooks/use-current-weather";
|
||||
import { ForecastPanel } from "@/components/forecast/forecast-panel";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -43,6 +44,7 @@ export function DashboardPage() {
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<DashboardWarnings />
|
||||
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
<FavoritesSection stations={stations} />
|
||||
<FeaturedStationsSection stations={stations} />
|
||||
|
||||
81
components/dashboard/weather-brief-card.tsx
Normal file
81
components/dashboard/weather-brief-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { LanguageToggle } from "@/components/ui/language-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 { useI18n } from "@/lib/i18n";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
@@ -60,15 +63,29 @@ export function SettingsPage() {
|
||||
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
||||
const { data: stations } = useWeatherStations();
|
||||
const { data: positions = [] } = useMeteoStationPositions();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
|
||||
const morningBriefEnabled = useWeatherStore((state) => state.morningBriefNotificationsEnabled);
|
||||
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
|
||||
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
|
||||
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
|
||||
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
|
||||
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
|
||||
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 effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
|
||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
@@ -99,14 +116,19 @@ export function SettingsPage() {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
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();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]);
|
||||
}, [effectiveProvince, language, morningBriefEnabled, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, vapidPublicKey]);
|
||||
|
||||
const notificationStatusLabel = useMemo(() => {
|
||||
switch (notificationStatus) {
|
||||
@@ -158,7 +180,12 @@ export function SettingsPage() {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
await savePushSubscription(subscription, effectiveProvince, language);
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
morningBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
});
|
||||
setNotificationsEnabled(true);
|
||||
setNotificationMessage(t("settings.notifications.saveSuccess"));
|
||||
} catch {
|
||||
@@ -180,6 +207,7 @@ export function SettingsPage() {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
setNotificationsEnabled(false);
|
||||
setMorningBriefEnabled(false);
|
||||
setNotificationMessage(t("settings.notifications.disableSuccess"));
|
||||
} catch {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
@@ -301,6 +347,23 @@ export function SettingsPage() {
|
||||
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||
</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">
|
||||
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
|
||||
<Bell className="size-4" />
|
||||
|
||||
16
lib/i18n.tsx
16
lib/i18n.tsx
@@ -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…",
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
52
lib/server-forecast.ts
Normal 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);
|
||||
}
|
||||
@@ -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
219
lib/weather-brief.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export interface WarningPushSubscription {
|
||||
province: Province;
|
||||
language: Language;
|
||||
enabled: boolean;
|
||||
morningBriefEnabled: boolean;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
locationName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user