feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s

This commit is contained in:
zv
2026-06-14 15:53:34 +02:00
parent d572c9cc53
commit 2182297adc
45 changed files with 739 additions and 212 deletions

View File

@@ -1,4 +1,6 @@
import type { DailyForecast, ForecastSource, HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherRegion } from "@/types/weather-region";
import { getWeatherCapabilities } from "@/types/weather-region";
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -59,18 +61,22 @@ function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
const region: WeatherRegion = raw.region === "GLOBAL" ? "GLOBAL" : "PL";
return {
latitude,
longitude,
region,
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
hourly: normalizeHourlyForecast(raw.hourly),
daily: normalizeDailyForecast(raw.daily),
sources: normalizeSources(raw.sources),
capabilities: raw.capabilities ?? getWeatherCapabilities(region),
unavailableReasons: Array.isArray(raw.unavailableReasons) ? raw.unavailableReasons.filter((value): value is string => typeof value === "string") : [],
};
}
export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
export async function fetchForecast(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
const response = await fetch(`/api/forecast?${params}`, { signal });
if (!response.ok) throw new Error("Unable to load forecast.");
return normalizeForecast(await response.json() as Partial<WeatherForecast>);

View File

@@ -8,6 +8,7 @@ import type {
RawWeatherForecast,
WeatherForecast,
} from "@/types/forecast";
import { getWeatherCapabilities, type WeatherRegion } from "@/types/weather-region";
const WARSAW_TIME_ZONE = "Europe/Warsaw";
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
@@ -96,17 +97,20 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
});
}
function normalizeOpenMeteoForecast(raw: RawWeatherForecast): WeatherForecast {
function normalizeOpenMeteoForecast(raw: RawWeatherForecast, region: WeatherRegion): WeatherForecast {
const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
return {
latitude,
longitude,
region,
timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE,
hourly: normalizeOpenMeteoHourly(raw.hourly),
daily: normalizeOpenMeteoDaily(raw.daily),
sources: ["open-meteo"],
capabilities: getWeatherCapabilities(region),
unavailableReasons: region === "GLOBAL" ? ["official-warnings"] : [],
};
}
@@ -188,8 +192,8 @@ function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecas
};
}
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast {
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload);
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null, region: WeatherRegion = "PL"): WeatherForecast {
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region);
const imgwHours = normalizeImgwHourly(imgwPayload);
let hasImgwHours = false;
const hourly = openMeteoForecast.hourly.map((hour) => {

View File

@@ -44,9 +44,12 @@ const translations = {
"settings.notifications.regionSelected": "Używaj lokalizacji wybranej w aplikacji",
"settings.notifications.regionManual": "Wybierz województwo ręcznie",
"settings.notifications.noProvince": "brak wybranego województwa",
"settings.notifications.globalRegion": "lokalizacja poza Polską",
"settings.notifications.globalAlertsUnavailable": "Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.",
"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.enableGlobalDescription": "Subskrypcja zostanie zapisana dla wybranej lokalizacji i użyta do briefów opartych o prognozę modelową.",
"settings.notifications.morningBrief": "Brief poranny o 7:00",
"settings.notifications.morningBriefDescription": "Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
@@ -71,7 +74,7 @@ const translations = {
"settings.notifications.statusDenied": "Powiadomienia są zablokowane w ustawieniach systemu lub przeglądarki.",
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
"settings.notifications.statusReady": "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.",
"settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW przez Web Push. W aktualnej wersji subskrypcje są przechowywane w pamięci procesu, więc produkcja wymaga podmiany magazynu na trwałą bazę lub KV.",
"settings.notifications.implementationNote": "Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.",
"pwa.install": "Zainstaluj",
"common.noData": "Brak danych",
"common.loading": "Ładowanie danych",
@@ -87,15 +90,18 @@ const translations = {
"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…",
"location.searchLabel": "Szukaj miejscowości",
"location.placeholder": "Wpisz miejscowość, np. Warszawa albo Rome…",
"location.clear": "Wyczyść wyszukiwanie",
"location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
"location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
"location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
"location.empty": "Nie znaleziono pasującej miejscowości.",
"location.nearest": "Najbliższa stacja IMGW",
"location.modelSource": "Źródło modelowe",
"location.currentSource": "{location}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km.",
"location.currentSourceGlobal": "{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.",
"location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}",
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial": "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
"location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
@@ -106,7 +112,7 @@ const translations = {
"location.gpsUse": "Użyj mojej lokalizacji",
"location.gpsLocating": "Ustalam lokalizację…",
"location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?",
"location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość, najbliższą stację IMGW i prognozę. Pozycja zostanie zaokrąglona do około 100 metrów.",
"location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość i prognozę. W Polsce dobierze też najbliższą stację IMGW. Pozycja zostanie zaokrąglona do około 100 metrów.",
"location.gpsAllow": "Użyj GPS",
"location.gpsNotNow": "Nie teraz",
"location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.",
@@ -129,9 +135,11 @@ const translations = {
"weather.wind": "Wiatr",
"weather.rainfall": "Suma opadu",
"weather.rainfall10m": "Opad 10 min",
"weather.currentPrecipitation": "Opad",
"weather.pressure": "Ciśnienie",
"weather.feelsLike": "Odczuwalna",
"weather.measurement": "pomiar",
"weather.modelUpdate": "aktualizacja modelu",
"weather.windDirection": "Kierunek wiatru",
"weather.calm": "Pogodnie",
"weather.humid": "Wilgotno",
@@ -177,7 +185,7 @@ const translations = {
"forecast.pastHour": "Miniona godzina",
"forecast.source": "Źródło prognozy:",
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.",
"forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.",
"forecast.sourceFallbackDescription": "Prognoza jest wyświetlana jako modelowa prognoza Open-Meteo.",
"forecast.error": "Nie udało się pobrać prognozy modelowej.",
"forecast.emptyTitle": "Brak prognozy",
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
@@ -217,6 +225,8 @@ const translations = {
"warnings.error": "Nie udało się pobrać ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski",
"warnings.globalUnavailableDescription": "Oficjalne ostrzeżenia pogodowe są obecnie obsługiwane tylko dla Polski przez IMGW. Dla tej lokalizacji możesz korzystać z prognozy modelowej Open-Meteo, ale nie traktujemy jej jako oficjalnego alertu.",
"warnings.drought": "Susza hydrologiczna",
"warnings.levelUnknown": "Poziom nieokreślony",
"warnings.level": "Stopień {level}",
@@ -291,9 +301,12 @@ const translations = {
"settings.notifications.regionSelected": "Use the location selected in the app",
"settings.notifications.regionManual": "Choose province manually",
"settings.notifications.noProvince": "no province selected",
"settings.notifications.globalRegion": "location outside Poland",
"settings.notifications.globalAlertsUnavailable": "Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.",
"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.enableGlobalDescription": "The subscription will be saved for the selected location and used for model forecast briefs.",
"settings.notifications.morningBrief": "Morning brief at 7:00",
"settings.notifications.morningBriefDescription": "Uses the selected location and model forecast to send a short daily summary to this device.",
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
@@ -318,7 +331,7 @@ const translations = {
"settings.notifications.statusDenied": "Notifications are blocked in system or browser settings.",
"settings.notifications.statusGranted": "This device has notification permission.",
"settings.notifications.statusReady": "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.",
"settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings through Web Push. This version stores subscriptions in process memory, so production needs a durable database or KV store.",
"settings.notifications.implementationNote": "The server checker sends new IMGW meteorological warnings and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.",
"pwa.install": "Install",
"common.noData": "No data",
"common.loading": "Loading data",
@@ -334,15 +347,18 @@ const translations = {
"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…",
"location.searchLabel": "Search places",
"location.placeholder": "Enter a place, e.g. Warsaw or Rome…",
"location.clear": "Clear search",
"location.error": "Unable to search for places. Try again.",
"location.preparing": "Preparing the nearest IMGW stations…",
"location.empty": "No matching place was found in Poland.",
"location.empty": "No matching place was found.",
"location.nearest": "Nearest IMGW station",
"location.modelSource": "Model source",
"location.currentSource": "{location}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} km away.",
"location.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.currentSourceGlobal": "{location}: coordinates are used for Open-Meteo model current conditions and forecast.",
"location.heroGlobalModelSource": "Open-Meteo model current conditions for: {location}",
"location.heroHybridLoading": "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
"location.heroHybridPartial": "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} km away",
"location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} km away",
@@ -353,7 +369,7 @@ const translations = {
"location.gpsUse": "Use my location",
"location.gpsLocating": "Finding your location…",
"location.gpsPromptTitle": "Show weather for your location?",
"location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place, the nearest IMGW station and the forecast. Your position will be rounded to approximately 100 metres.",
"location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place and forecast. In Poland it will also match the nearest IMGW station. Your position will be rounded to approximately 100 metres.",
"location.gpsAllow": "Use GPS",
"location.gpsNotNow": "Not now",
"location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.",
@@ -376,9 +392,11 @@ const translations = {
"weather.wind": "Wind",
"weather.rainfall": "Rainfall total",
"weather.rainfall10m": "Rainfall 10 min",
"weather.currentPrecipitation": "Precipitation",
"weather.pressure": "Pressure",
"weather.feelsLike": "Feels like",
"weather.measurement": "measurement",
"weather.modelUpdate": "model update",
"weather.windDirection": "Wind direction",
"weather.calm": "Fair",
"weather.humid": "Humid",
@@ -424,7 +442,7 @@ const translations = {
"forecast.pastHour": "Past hour",
"forecast.source": "Forecast source:",
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.",
"forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.",
"forecast.sourceFallbackDescription": "The forecast is shown as an Open-Meteo model forecast.",
"forecast.error": "Unable to load the model forecast.",
"forecast.emptyTitle": "Forecast unavailable",
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
@@ -464,6 +482,8 @@ const translations = {
"warnings.error": "Unable to load meteorological or hydrological warnings.",
"warnings.emptyTitle": "No active warnings",
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
"warnings.globalUnavailableTitle": "Official warnings only for Poland",
"warnings.globalUnavailableDescription": "Official weather warnings are currently supported only for Poland through IMGW. For this location you can use the Open-Meteo model forecast, but it is not treated as an official alert.",
"warnings.drought": "Hydrological drought",
"warnings.levelUnknown": "Level not specified",
"warnings.level": "Level {level}",

View File

@@ -1,5 +1,6 @@
import { toNumber } from "@/lib/weather-utils";
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
import type { WeatherRegion } from "@/types/weather-region";
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
@@ -104,6 +105,12 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
return {
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
region: "PL",
source: "IMGW",
sourceLabel: "IMGW Hybrid",
sourceType: fullRow ? "hybrid" : "model",
isOfficial: true,
fetchedAt: new Date().toISOString(),
measuredAt: row.measuredAt,
temperature: toCelsius(row.row.Temperature),
feelsLike: toCelsius(row.row.Chill),
@@ -126,3 +133,10 @@ export async function fetchImgwCurrentWeather(latitude: number, longitude: numbe
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse);
}
export async function fetchCurrentWeather(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), region });
const response = await fetch(`/api/current-weather?${params}`, { signal });
if (!response.ok) throw new Error("Nie udało się pobrać bieżących warunków pogodowych.");
return await response.json() as ImgwCurrentWeather | null;
}

View File

@@ -1,6 +1,7 @@
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
import { getCountyTerytForLocation } from "@/lib/warning-regions";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
const stationAliases: Record<string, string> = {
gdansk: "gdanskrebiechowo",
@@ -64,6 +65,12 @@ export function findNearestSynopStation(
name: location.name,
province: location.province,
district: location.district,
country: "Polska",
countryCode: "PL",
admin1: location.province,
admin2: location.district,
timezone: "Europe/Warsaw",
region: "PL",
countyTeryt: getCountyTerytForLocation(location.province, location.district, location.name),
latitude: location.latitude,
longitude: location.longitude,
@@ -72,3 +79,35 @@ export function findNearestSynopStation(
distanceKm: Math.round(nearest.distanceKm),
};
}
export function createSelectedLocation(
location: Pick<LocationSearchResult, "name" | "province" | "district" | "country" | "countryCode" | "admin1" | "admin2" | "timezone" | "region" | "latitude" | "longitude">,
stations: LocatedSynopStation[],
): SelectedLocation {
const countryCode = location.countryCode?.toUpperCase() ?? null;
const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
const nearest = region === "PL"
? stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
}, null)
: null;
return {
name: location.name,
province: location.province,
district: location.district,
country: location.country,
countryCode,
admin1: location.admin1,
admin2: location.admin2,
timezone: location.timezone,
region,
countyTeryt: region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null,
latitude: location.latitude,
longitude: location.longitude,
stationId: nearest?.station.id ?? null,
stationName: nearest?.station.name ?? null,
distanceKm: nearest ? Math.round(nearest.distanceKm) : null,
};
}

View File

@@ -1,6 +1,7 @@
import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
import type { WeatherRegion } from "@/types/weather-region";
interface VapidKeyResponse {
configured: boolean;
@@ -14,17 +15,20 @@ export async function fetchVapidPublicKey(signal?: AbortSignal) {
}
interface SavePushSubscriptionOptions {
region?: WeatherRegion;
officialWarningsEnabled?: boolean;
morningBriefEnabled?: boolean;
tomorrowBriefEnabled?: boolean;
latitude?: number | null;
longitude?: number | null;
locationName?: string | null;
timezone?: string | null;
countyTeryt?: string | null;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
}
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) {
export async function savePushSubscription(subscription: PushSubscription, province: Province | null, language: Language, options: SavePushSubscriptionOptions = {}) {
const response = await fetch("/api/notifications/subscriptions", {
method: "POST",
headers: { "content-type": "application/json" },
@@ -32,12 +36,14 @@ export async function savePushSubscription(subscription: PushSubscription, provi
subscription: subscription.toJSON(),
province,
language,
enabled: true,
region: options.region ?? "PL",
enabled: options.officialWarningsEnabled ?? true,
morningBriefEnabled: options.morningBriefEnabled === true,
tomorrowBriefEnabled: options.tomorrowBriefEnabled === true,
latitude: options.latitude ?? null,
longitude: options.longitude ?? null,
locationName: options.locationName ?? null,
timezone: options.timezone ?? null,
countyTeryt: options.countyTeryt ?? null,
temperatureUnit: options.temperatureUnit,
windSpeedUnit: options.windSpeedUnit,

View File

@@ -1,9 +1,9 @@
import webPush, { type PushSubscription } from "web-push";
import type { Language } from "@/lib/i18n";
import { formatProvinceName } from "@/lib/provinces";
import type { WeatherBrief } from "@/lib/weather-brief";
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;
@@ -49,13 +49,13 @@ function formatWarningValidity(warning: WeatherWarning, language: Language) {
}
function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) {
const province = formatProvinceName(preference.province, preference.language);
const region = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "wtr.";
const title = warning.title || (preference.language === "pl" ? "Ostrzeżenie meteorologiczne" : "Weather warning");
const validity = formatWarningValidity(warning, preference.language);
const level = getWarningLevelLabel(warning, preference.language);
const bodyParts = preference.language === "pl"
? [`${province}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
: [`${province}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
? [`${region}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
return {
title: `IMGW: ${title}`,
@@ -73,12 +73,12 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
export async function sendTestNotification(preference: WarningPushSubscription) {
ensureWebPushConfigured();
const province = formatProvinceName(preference.province, preference.language);
const location = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "Open-Meteo";
const payload = JSON.stringify({
title: preference.language === "pl" ? "wtr.: test powiadomień IMGW" : "wtr.: IMGW notification test",
title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test",
body: preference.language === "pl"
? `Powiadomienia są aktywne dla województwa: ${province}.`
: `Notifications are active for: ${province}.`,
? `Powiadomienia są aktywne dla: ${location}.`
: `Notifications are active for: ${location}.`,
url: "/settings",
});
await webPush.sendNotification(preference.subscription as PushSubscription, payload);

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
import type { WarningPushSubscription } from "@/types/notifications";
import type { Province } from "@/types/province";
import type { WeatherRegion } from "@/types/weather-region";
interface PushDatabase {
database: Database.Database;
@@ -13,6 +14,7 @@ interface PushSubscriptionRow {
endpoint: string;
subscription_json: string;
province: string;
region?: string;
language: string;
enabled: number;
morning_brief_enabled: number;
@@ -20,6 +22,7 @@ interface PushSubscriptionRow {
latitude: number | null;
longitude: number | null;
location_name: string | null;
timezone: string | null;
county_teryt: string | null;
temperature_unit: string | null;
wind_speed_unit: string | null;
@@ -50,7 +53,8 @@ function initializeDatabase(database: Database.Database) {
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
subscription_json TEXT NOT NULL,
province TEXT NOT NULL,
province TEXT NOT NULL DEFAULT 'global',
region TEXT NOT NULL DEFAULT 'PL',
language TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
morning_brief_enabled INTEGER NOT NULL DEFAULT 0,
@@ -58,6 +62,7 @@ function initializeDatabase(database: Database.Database) {
latitude REAL,
longitude REAL,
location_name TEXT,
timezone TEXT,
county_teryt TEXT,
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}',
@@ -89,6 +94,13 @@ function initializeDatabase(database: Database.Database) {
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
`);
const columns = database.prepare("PRAGMA table_info(push_subscriptions)").all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "region")) {
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN region TEXT NOT NULL DEFAULT 'PL'").run();
}
if (!columns.some((column) => column.name === "timezone")) {
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run();
}
}
function getDatabase() {
@@ -105,10 +117,12 @@ function getDatabase() {
function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null {
try {
const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"];
const region: WeatherRegion = row.region === "GLOBAL" || row.province === "global" ? "GLOBAL" : "PL";
return {
endpoint: row.endpoint,
subscription,
province: row.province as Province,
province: row.province === "global" ? null : row.province as Province,
region,
language: row.language === "en" ? "en" : "pl",
enabled: row.enabled === 1,
morningBriefEnabled: row.morning_brief_enabled === 1,
@@ -116,6 +130,7 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
latitude: row.latitude,
longitude: row.longitude,
locationName: row.location_name,
timezone: row.timezone,
countyTeryt: row.county_teryt,
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT,
@@ -133,6 +148,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
endpoint,
subscription_json,
province,
region,
language,
enabled,
morning_brief_enabled,
@@ -140,6 +156,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
latitude,
longitude,
location_name,
timezone,
county_teryt,
temperature_unit,
wind_speed_unit,
@@ -149,6 +166,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
@endpoint,
@subscriptionJson,
@province,
@region,
@language,
@enabled,
@morningBriefEnabled,
@@ -156,6 +174,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
@latitude,
@longitude,
@locationName,
@timezone,
@countyTeryt,
@temperatureUnit,
@windSpeedUnit,
@@ -165,6 +184,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
ON CONFLICT(endpoint) DO UPDATE SET
subscription_json = excluded.subscription_json,
province = excluded.province,
region = excluded.region,
language = excluded.language,
enabled = excluded.enabled,
morning_brief_enabled = excluded.morning_brief_enabled,
@@ -172,6 +192,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
latitude = excluded.latitude,
longitude = excluded.longitude,
location_name = excluded.location_name,
timezone = excluded.timezone,
county_teryt = excluded.county_teryt,
temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_unit,
@@ -179,7 +200,8 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
`).run({
endpoint: subscription.endpoint,
subscriptionJson: JSON.stringify(subscription.subscription),
province: subscription.province,
province: subscription.province ?? "global",
region: subscription.region,
language: subscription.language,
enabled: subscription.enabled ? 1 : 0,
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
@@ -187,6 +209,7 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
latitude: subscription.latitude,
longitude: subscription.longitude,
locationName: subscription.locationName,
timezone: subscription.timezone,
countyTeryt: subscription.countyTeryt,
temperatureUnit: subscription.temperatureUnit,
windSpeedUnit: subscription.windSpeedUnit,

View File

@@ -0,0 +1,113 @@
import { normalizeImgwCurrentWeather } from "@/lib/imgw-current-api";
import { toNumber } from "@/lib/weather-utils";
import type { CurrentWeatherCondition, ImgwCurrentWeather, RawImgwHybridWeatherResponse } from "@/types/imgw-current";
import type { WeatherRegion } from "@/types/weather-region";
const IMGW_HYBRID_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
// This browser token is published by the official meteo.imgw.pl frontend.
const IMGW_HYBRID_TOKEN = "p4DXKjsYadfBV21TYrDk";
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
interface RawOpenMeteoCurrentResponse {
current?: {
time?: string;
temperature_2m?: unknown;
apparent_temperature?: unknown;
relative_humidity_2m?: unknown;
precipitation?: unknown;
rain?: unknown;
showers?: unknown;
snowfall?: unknown;
weather_code?: unknown;
cloud_cover?: unknown;
pressure_msl?: unknown;
wind_speed_10m?: unknown;
wind_direction_10m?: unknown;
};
}
function getOpenMeteoCondition(weatherCode: number | null, rain: number | null, showers: number | null, snowfall: number | null): CurrentWeatherCondition {
if (weatherCode !== null && weatherCode >= 95) return "thunderstorm";
if ((snowfall ?? 0) > 0 || (weatherCode !== null && weatherCode >= 71 && weatherCode <= 77)) return "snow";
if ((rain ?? 0) > 0 || (showers ?? 0) > 0 || (weatherCode !== null && weatherCode >= 51 && weatherCode <= 67) || (weatherCode !== null && weatherCode >= 80 && weatherCode <= 82)) return "rain";
return null;
}
function normalizeOpenMeteoCurrent(payload: RawOpenMeteoCurrentResponse): ImgwCurrentWeather | null {
const current = payload.current;
if (!current?.time) return null;
const measuredAt = new Date(current.time).toISOString();
const weatherCode = toNumber(current.weather_code);
const rain = toNumber(current.rain);
const showers = toNumber(current.showers);
const snowfall = toNumber(current.snowfall);
const precipitation = toNumber(current.precipitation);
return {
coverage: "global-model",
region: "GLOBAL",
source: "OPEN_METEO",
sourceLabel: "Open-Meteo",
sourceType: "model",
isOfficial: false,
fetchedAt: new Date().toISOString(),
measuredAt,
temperature: toNumber(current.temperature_2m),
feelsLike: toNumber(current.apparent_temperature),
windSpeed: toNumber(current.wind_speed_10m),
windDirection: toNumber(current.wind_direction_10m),
humidity: toNumber(current.relative_humidity_2m),
pressure: toNumber(current.pressure_msl),
precipitation10m: precipitation,
rainfall10m: rain ?? showers ?? precipitation,
snowfall10m: snowfall,
cloudCover: toNumber(current.cloud_cover),
weatherCode,
condition: getOpenMeteoCondition(weatherCode, rain, showers, snowfall),
};
}
export async function fetchImgwHybridCurrentWeather(latitude: number, longitude: number) {
const params = new URLSearchParams({
token: IMGW_HYBRID_TOKEN,
lat: String(latitude),
lon: String(longitude),
m: "hybrid",
});
const response = await fetch(`${IMGW_HYBRID_URL}?${params}`, { next: { revalidate: 120 } });
if (!response.ok) throw new Error("IMGW Hybrid service is unavailable.");
return await response.json() as RawImgwHybridWeatherResponse;
}
export async function fetchServerCurrentWeather(latitude: number, longitude: number, region: WeatherRegion) {
if (region === "PL") {
return normalizeImgwCurrentWeather(await fetchImgwHybridCurrentWeather(latitude, longitude));
}
const params = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
current: [
"temperature_2m",
"apparent_temperature",
"relative_humidity_2m",
"precipitation",
"rain",
"showers",
"snowfall",
"weather_code",
"cloud_cover",
"pressure_msl",
"wind_speed_10m",
"wind_direction_10m",
].join(","),
timezone: "auto",
wind_speed_unit: "ms",
precipitation_unit: "mm",
timeformat: "iso8601",
forecast_days: "1",
});
const response = await fetch(`${OPEN_METEO_FORECAST_URL}?${params}`, { next: { revalidate: 120 } });
if (!response.ok) throw new Error("Open-Meteo current conditions are unavailable.");
return normalizeOpenMeteoCurrent(await response.json() as RawOpenMeteoCurrentResponse);
}

View File

@@ -1,5 +1,6 @@
import { mergeForecastSources } from "@/lib/forecast-merge";
import type { RawImgwForecastResponse, RawWeatherForecast } from "@/types/forecast";
import type { WeatherRegion } from "@/types/weather-region";
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
const IMGW_FORECAST_URL = "https://meteo.imgw.pl/api/v1/forecast/fcapi";
@@ -23,13 +24,13 @@ async function readImgwPayload(response: Response | null) {
}
}
export async function fetchServerForecast(latitude: number, longitude: number) {
export async function fetchServerForecast(latitude: number, longitude: number, region: WeatherRegion = "PL") {
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",
timezone: region === "PL" ? "Europe/Warsaw" : "auto",
forecast_days: "7",
wind_speed_unit: "ms",
});
@@ -42,11 +43,13 @@ export async function fetchServerForecast(latitude: number, longitude: number) {
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),
region === "PL"
? fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null)
: Promise.resolve(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);
return mergeForecastSources(openMeteoPayload, imgwPayload, region);
}

View File

@@ -6,6 +6,8 @@ import type { SelectedLocation } from "@/types/location";
import type { Province } from "@/types/province";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
import type { WeatherRegion } from "@/types/weather-region";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
type NotificationProvinceMode = "selected" | "manual";
@@ -61,6 +63,30 @@ export const useWeatherStore = create<WeatherStore>()(
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
}),
{ name: "wtr:preferences" },
{
name: "wtr:preferences",
migrate: (persisted) => {
const state = persisted as Partial<WeatherStore> | undefined;
const location = state?.selectedLocation as (Partial<SelectedLocation> & { region?: WeatherRegion }) | null | undefined;
if (!state || !location) return persisted;
const countryCode = location.countryCode ?? (location.province ? "PL" : null);
const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
return {
...state,
selectedLocation: {
...location,
country: location.country ?? (countryCode === "PL" ? "Polska" : null),
countryCode,
admin1: location.admin1 ?? location.province ?? null,
admin2: location.admin2 ?? location.district ?? null,
timezone: location.timezone ?? (countryCode === "PL" ? "Europe/Warsaw" : null),
region,
stationId: location.stationId ?? null,
stationName: location.stationName ?? null,
distanceKm: location.distanceKm ?? null,
},
};
},
},
),
);