chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
This commit is contained in:
@@ -23,21 +23,27 @@ export const APP_SECTION_SETTINGS = [
|
||||
defaultVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type AppSectionId = typeof APP_SECTION_SETTINGS[number]["id"];
|
||||
export type AppSectionId = (typeof APP_SECTION_SETTINGS)[number]["id"];
|
||||
|
||||
export type AppSectionVisibility = Record<AppSectionId, boolean>;
|
||||
|
||||
export const DEFAULT_APP_SECTION_VISIBILITY = APP_SECTION_SETTINGS.reduce((visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}), {} as AppSectionVisibility);
|
||||
export const DEFAULT_APP_SECTION_VISIBILITY = APP_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}),
|
||||
{} as AppSectionVisibility,
|
||||
);
|
||||
|
||||
export function normalizeAppSectionVisibility(value: unknown): AppSectionVisibility {
|
||||
const stored = value && typeof value === "object" ? value as Partial<Record<AppSectionId, unknown>> : {};
|
||||
return APP_SECTION_SETTINGS.reduce((visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}), {} as AppSectionVisibility);
|
||||
const stored = value && typeof value === "object" ? (value as Partial<Record<AppSectionId, unknown>>) : {};
|
||||
return APP_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}),
|
||||
{} as AppSectionVisibility,
|
||||
);
|
||||
}
|
||||
|
||||
export function isAppNavItemVisible(href: string, visibility: AppSectionVisibility) {
|
||||
|
||||
@@ -20,19 +20,25 @@ export const DASHBOARD_SECTION_SETTINGS = [
|
||||
defaultVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type DashboardSectionId = typeof DASHBOARD_SECTION_SETTINGS[number]["id"];
|
||||
export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"];
|
||||
|
||||
export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>;
|
||||
|
||||
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce((visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}), {} as DashboardSectionVisibility);
|
||||
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: section.defaultVisible,
|
||||
}),
|
||||
{} as DashboardSectionVisibility,
|
||||
);
|
||||
|
||||
export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility {
|
||||
const stored = value && typeof value === "object" ? value as Partial<Record<DashboardSectionId, unknown>> : {};
|
||||
return DASHBOARD_SECTION_SETTINGS.reduce((visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}), {} as DashboardSectionVisibility);
|
||||
const stored = value && typeof value === "object" ? (value as Partial<Record<DashboardSectionId, unknown>>) : {};
|
||||
return DASHBOARD_SECTION_SETTINGS.reduce(
|
||||
(visibility, section) => ({
|
||||
...visibility,
|
||||
[section.id]: typeof stored[section.id] === "boolean" ? stored[section.id] : section.defaultVisible,
|
||||
}),
|
||||
{} as DashboardSectionVisibility,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,48 +19,57 @@ function normalizeSources(value: unknown): ForecastSource[] {
|
||||
}
|
||||
|
||||
function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<HourlyForecast>;
|
||||
const time = readString(row.time);
|
||||
if (!time) return [];
|
||||
return [{
|
||||
time,
|
||||
temperature: readNumber(row.temperature),
|
||||
feelsLike: readNumber(row.feelsLike),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
windSpeed: readNumber(row.windSpeed),
|
||||
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||
}];
|
||||
}) : [];
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<HourlyForecast>;
|
||||
const time = readString(row.time);
|
||||
if (!time) return [];
|
||||
return [
|
||||
{
|
||||
time,
|
||||
temperature: readNumber(row.temperature),
|
||||
feelsLike: readNumber(row.feelsLike),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
windSpeed: readNumber(row.windSpeed),
|
||||
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeDailyForecast(value: unknown): DailyForecast[] {
|
||||
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<DailyForecast>;
|
||||
const date = readString(row.date);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
date,
|
||||
temperatureMax: readNumber(row.temperatureMax),
|
||||
temperatureMin: readNumber(row.temperatureMin),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
sunrise: readString(row.sunrise),
|
||||
sunset: readString(row.sunset),
|
||||
sources: normalizeSources(row.sources),
|
||||
}];
|
||||
}) : [];
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<DailyForecast>;
|
||||
const date = readString(row.date);
|
||||
if (!date) return [];
|
||||
return [
|
||||
{
|
||||
date,
|
||||
temperatureMax: readNumber(row.temperatureMax),
|
||||
temperatureMin: readNumber(row.temperatureMin),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
sunrise: readString(row.sunrise),
|
||||
sunset: readString(row.sunset),
|
||||
sources: normalizeSources(row.sources),
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
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.");
|
||||
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,
|
||||
@@ -71,7 +80,9 @@ function normalizeForecast(raw: Partial<WeatherForecast>): WeatherForecast {
|
||||
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") : [],
|
||||
unavailableReasons: Array.isArray(raw.unavailableReasons)
|
||||
? raw.unavailableReasons.filter((value): value is string => typeof value === "string")
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,5 +90,5 @@ export async function fetchForecast(latitude: number, longitude: number, region:
|
||||
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>);
|
||||
return normalizeForecast((await response.json()) as Partial<WeatherForecast>);
|
||||
}
|
||||
|
||||
@@ -66,16 +66,18 @@ function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecas
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const time = readString(series, "time", index);
|
||||
if (!time) return [];
|
||||
return [{
|
||||
time,
|
||||
temperature: readNumber(series, "temperature_2m", index),
|
||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
||||
precipitation: readNumber(series, "precipitation", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||
source: "open-meteo" as const,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
time,
|
||||
temperature: readNumber(series, "temperature_2m", index),
|
||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
||||
precipitation: readNumber(series, "precipitation", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||
source: "open-meteo" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,24 +85,27 @@ function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const date = readString(series, "time", index);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
date,
|
||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
||||
precipitation: readNumber(series, "precipitation_sum", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
sunrise: readString(series, "sunrise", index),
|
||||
sunset: readString(series, "sunset", index),
|
||||
sources: ["open-meteo" as const],
|
||||
}];
|
||||
return [
|
||||
{
|
||||
date,
|
||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
||||
precipitation: readNumber(series, "precipitation_sum", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
sunrise: readString(series, "sunrise", index),
|
||||
sunset: readString(series, "sunset", index),
|
||||
sources: ["open-meteo" as const],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||
throw new Error("Forecast service returned invalid coordinates.");
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
@@ -169,9 +174,11 @@ function getWeatherCodePriority(code: number | null) {
|
||||
|
||||
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
|
||||
if (!hours.length) return fallback;
|
||||
return hours.reduce((selected, hour) => (
|
||||
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected
|
||||
), null as number | null);
|
||||
return hours.reduce(
|
||||
(selected, hour) =>
|
||||
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected,
|
||||
null as number | null,
|
||||
);
|
||||
}
|
||||
|
||||
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) {
|
||||
@@ -183,16 +190,32 @@ function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecas
|
||||
const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
|
||||
return {
|
||||
...day,
|
||||
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax),
|
||||
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin),
|
||||
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability),
|
||||
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation),
|
||||
temperatureMax: getMaximum(
|
||||
dayHours.map((hour) => hour.temperature),
|
||||
day.temperatureMax,
|
||||
),
|
||||
temperatureMin: getMinimum(
|
||||
dayHours.map((hour) => hour.temperature),
|
||||
day.temperatureMin,
|
||||
),
|
||||
precipitationProbability: getMaximum(
|
||||
dayHours.map((hour) => hour.precipitationProbability),
|
||||
day.precipitationProbability,
|
||||
),
|
||||
precipitation: getTotal(
|
||||
dayHours.map((hour) => hour.precipitation),
|
||||
day.precipitation,
|
||||
),
|
||||
weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
|
||||
sources: getSources(dayHours, day.sources),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null, region: WeatherRegion = "PL"): WeatherForecast {
|
||||
export function mergeForecastSources(
|
||||
openMeteoPayload: RawWeatherForecast,
|
||||
imgwPayload?: RawImgwForecastResponse | null,
|
||||
region: WeatherRegion = "PL",
|
||||
): WeatherForecast {
|
||||
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload, region);
|
||||
const imgwHours = normalizeImgwHourly(imgwPayload);
|
||||
let hasImgwHours = false;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Language, TranslationKey } from "@/lib/i18n";
|
||||
import { translate } from "@/lib/i18n";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature, formatWindSpeed } from "@/lib/weather-utils";
|
||||
import {
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
formatTemperature,
|
||||
formatWindSpeed,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
@@ -24,14 +29,20 @@ function isDryWeatherCode(code: number | null) {
|
||||
return code === null || (code >= 0 && code <= 3) || code === 45 || code === 48;
|
||||
}
|
||||
|
||||
export function getEffectiveHourlyWeatherCode(hour: Pick<HourlyForecast, "weatherCode" | "precipitation" | "precipitationProbability">) {
|
||||
export function getEffectiveHourlyWeatherCode(
|
||||
hour: Pick<HourlyForecast, "weatherCode" | "precipitation" | "precipitationProbability">,
|
||||
) {
|
||||
const hasMeasuredPrecipitation = (hour.precipitation ?? 0) > 0;
|
||||
const hasLikelyPrecipitation = (hour.precipitationProbability ?? 0) >= 70;
|
||||
if (isDryWeatherCode(hour.weatherCode) && (hasMeasuredPrecipitation || hasLikelyPrecipitation)) return 61;
|
||||
return hour.weatherCode;
|
||||
}
|
||||
|
||||
export function formatForecastTemperature(value: number | null, language: Language, unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
|
||||
export function formatForecastTemperature(
|
||||
value: number | null,
|
||||
language: Language,
|
||||
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
) {
|
||||
if (value === null) return "—";
|
||||
return formatTemperature(value, language, unit);
|
||||
}
|
||||
@@ -41,7 +52,11 @@ export function formatForecastRainfall(value: number | null, language: Language)
|
||||
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
|
||||
}
|
||||
|
||||
export function formatForecastWind(value: number | null, language: Language, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
|
||||
export function formatForecastWind(
|
||||
value: number | null,
|
||||
language: Language,
|
||||
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
) {
|
||||
if (value === null) return "—";
|
||||
return formatWindSpeed(value, language, unit);
|
||||
}
|
||||
@@ -76,22 +91,24 @@ function getForecastHourOfDay(time: string) {
|
||||
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
|
||||
const currentHour = getWarsawForecastHour();
|
||||
const currentTimestamp = parseForecastHour(currentHour);
|
||||
const upcomingHours = currentTimestamp === null
|
||||
? hours.filter((hour) => hour.time >= currentHour)
|
||||
: hours.filter((hour) => {
|
||||
const hourTimestamp = parseForecastHour(hour.time);
|
||||
return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
|
||||
});
|
||||
const upcomingHours =
|
||||
currentTimestamp === null
|
||||
? hours.filter((hour) => hour.time >= currentHour)
|
||||
: hours.filter((hour) => {
|
||||
const hourTimestamp = parseForecastHour(hour.time);
|
||||
return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
|
||||
});
|
||||
|
||||
if (upcomingHours.length) return upcomingHours.slice(0, limit);
|
||||
|
||||
const currentHourOfDay = getForecastHourOfDay(currentHour);
|
||||
const fallbackHours = currentHourOfDay === null
|
||||
? hours
|
||||
: hours.filter((hour) => {
|
||||
const forecastHourOfDay = getForecastHourOfDay(hour.time);
|
||||
return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay;
|
||||
});
|
||||
const fallbackHours =
|
||||
currentHourOfDay === null
|
||||
? hours
|
||||
: hours.filter((hour) => {
|
||||
const forecastHourOfDay = getForecastHourOfDay(hour.time);
|
||||
return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay;
|
||||
});
|
||||
|
||||
return (fallbackHours.length ? fallbackHours : hours).slice(0, limit);
|
||||
}
|
||||
|
||||
213
lib/i18n.tsx
213
lib/i18n.tsx
@@ -21,17 +21,21 @@ const translations = {
|
||||
"theme.dark": "Włącz ciemny motyw",
|
||||
"settings.section": "Preferencje",
|
||||
"settings.title": "Ustawienia",
|
||||
"settings.description": "Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.",
|
||||
"settings.description":
|
||||
"Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.",
|
||||
"settings.language.title": "Język",
|
||||
"settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
|
||||
"settings.language.description":
|
||||
"Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
|
||||
"settings.theme.title": "Motyw",
|
||||
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
|
||||
"settings.dashboardSections.title": "Strona główna",
|
||||
"settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.",
|
||||
"settings.dashboardSections.weatherBrief.title": "Brief dnia",
|
||||
"settings.dashboardSections.weatherBrief.description": "Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.",
|
||||
"settings.dashboardSections.weatherBrief.description":
|
||||
"Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.",
|
||||
"settings.dashboardSections.featuredStations.title": "Szybki wybór",
|
||||
"settings.dashboardSections.featuredStations.description": "Pokazuje listę popularnych lokalizacji na dole strony głównej.",
|
||||
"settings.dashboardSections.featuredStations.description":
|
||||
"Pokazuje listę popularnych lokalizacji na dole strony głównej.",
|
||||
"settings.appSections.title": "Sekcje aplikacji",
|
||||
"settings.appSections.description": "Wybierz, które dodatkowe widoki mają być widoczne w nawigacji.",
|
||||
"settings.appSections.warnings.title": "Ostrzeżenia",
|
||||
@@ -39,33 +43,41 @@ const translations = {
|
||||
"settings.appSections.hydro.title": "Hydro",
|
||||
"settings.appSections.hydro.description": "Pokazuje widok monitoringu hydrologicznego IMGW w głównej nawigacji.",
|
||||
"settings.units.temperature.title": "Jednostka temperatury",
|
||||
"settings.units.temperature.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.temperature.description":
|
||||
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.temperature.label": "Wybierz jednostkę temperatury",
|
||||
"settings.units.temperature.c": "°C",
|
||||
"settings.units.temperature.f": "°F",
|
||||
"settings.units.wind.title": "Jednostka wiatru",
|
||||
"settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.wind.description":
|
||||
"Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
|
||||
"settings.units.wind.label": "Wybierz jednostkę wiatru",
|
||||
"settings.units.wind.ms": "m/s",
|
||||
"settings.units.wind.kmh": "km/h",
|
||||
"settings.units.wind.mph": "mph",
|
||||
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo",
|
||||
"settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
|
||||
"settings.notifications.description":
|
||||
"Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
|
||||
"settings.notifications.regionTitle": "Obszar alertów",
|
||||
"settings.notifications.regionDescription": "Aktualnie wybrany obszar dla przyszłych powiadomień: {province}.",
|
||||
"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.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.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.morningBriefDescription":
|
||||
"Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
|
||||
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
|
||||
"settings.notifications.tomorrowBriefDescription": "Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
|
||||
"settings.notifications.tomorrowBriefDescription":
|
||||
"Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
|
||||
"settings.notifications.enableDevice": "Włącz na tym urządzeniu",
|
||||
"settings.notifications.disable": "Wyłącz na tym urządzeniu",
|
||||
"settings.notifications.enabledStatus": "Włączone",
|
||||
@@ -82,11 +94,14 @@ const translations = {
|
||||
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
|
||||
"settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.",
|
||||
"settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.",
|
||||
"settings.notifications.statusNeedsInstall": "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
|
||||
"settings.notifications.statusNeedsInstall":
|
||||
"Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
|
||||
"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 oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.",
|
||||
"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 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",
|
||||
@@ -95,9 +110,11 @@ const translations = {
|
||||
"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.description":
|
||||
"Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.",
|
||||
"brief.tomorrowLabel": "Jutro",
|
||||
"brief.pushHint": "Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
|
||||
"brief.pushHint":
|
||||
"Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
|
||||
"brief.error": "Nie udało się przygotować briefu dnia.",
|
||||
"brief.emptyTitle": "Brak briefu",
|
||||
"brief.emptyDescription": "Prognoza nie zawiera wystarczających danych dla najbliższej doby.",
|
||||
@@ -110,12 +127,15 @@ const translations = {
|
||||
"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.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.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",
|
||||
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
|
||||
"location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
|
||||
@@ -124,14 +144,18 @@ 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ść i prognozę. W Polsce dobierze też najbliższą stację IMGW. 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.",
|
||||
"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.",
|
||||
"location.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.",
|
||||
"location.gpsDenied": "Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.",
|
||||
"location.gpsDenied":
|
||||
"Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.",
|
||||
"location.gpsTimeout": "Nie udało się ustalić lokalizacji w wymaganym czasie. Spróbuj ponownie.",
|
||||
"location.gpsPositionUnavailable": "Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.",
|
||||
"location.gpsPositionUnavailable":
|
||||
"Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.",
|
||||
"location.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.",
|
||||
"location.gpsFallbackName": "Bieżąca lokalizacja",
|
||||
"location.gpsSelected": "Wybrano lokalizację: {location}.",
|
||||
@@ -171,7 +195,8 @@ const translations = {
|
||||
"weather.temperatureDetail": "Temperatura powietrza",
|
||||
"forecast.label": "Prognoza modelowa",
|
||||
"forecast.title": "Najbliższe godziny i dni",
|
||||
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
|
||||
"forecast.description":
|
||||
"Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
|
||||
"forecast.hourly": "Najbliższe 24 godziny",
|
||||
"forecast.daily": "Prognoza 7-dniowa",
|
||||
"forecast.today": "Dzisiaj",
|
||||
@@ -196,7 +221,8 @@ const translations = {
|
||||
"forecast.maxProbability": "Maks. szansa opadu",
|
||||
"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.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 wyświetlana jako modelowa prognoza Open-Meteo.",
|
||||
"forecast.error": "Nie udało się pobrać prognozy modelowej.",
|
||||
"forecast.emptyTitle": "Brak prognozy",
|
||||
@@ -214,7 +240,8 @@ const translations = {
|
||||
"station.all": "Wszystkie stacje",
|
||||
"station.label": "Stacja {name}",
|
||||
"station.parameters": "Aktualne parametry",
|
||||
"station.parametersDescription": "Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.",
|
||||
"station.parametersDescription":
|
||||
"Najnowszy pomiar udostępniony przez IMGW. Brakujące wartości są oznaczone bez uzupełniania ich danymi szacunkowymi.",
|
||||
"station.error": "Nie udało się pobrać danych wybranej stacji IMGW.",
|
||||
"station.quality": "Jakość danych",
|
||||
"station.lastMeasurementImgw": "Ostatni pomiar IMGW",
|
||||
@@ -224,21 +251,26 @@ const translations = {
|
||||
"station.publicApi": "Publiczne API IMGW",
|
||||
"snapshot.label": "Snapshot pomiarowy",
|
||||
"snapshot.title": "Aktualne proporcje parametrów",
|
||||
"snapshot.description": "Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
|
||||
"snapshot.description":
|
||||
"Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
|
||||
"warnings.section": "Komunikaty IMGW",
|
||||
"warnings.title": "Ostrzeżenia",
|
||||
"warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
|
||||
"warnings.description":
|
||||
"Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
|
||||
"warnings.myProvince": "Mój obszar",
|
||||
"warnings.myProvinceDescription": "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
|
||||
"warnings.myProvinceDescription":
|
||||
"Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
|
||||
"warnings.myProvinceEmptyTitle": "Brak ostrzeżeń dla Twojego obszaru",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
|
||||
"warnings.myProvinceEmptyDescription":
|
||||
"IMGW nie publikuje obecnie aktywnych ostrzeżeń dotyczących obszaru {province}.",
|
||||
"warnings.otherRegions": "Pozostałe regiony",
|
||||
"warnings.otherRegionsDescription": "Aktywne komunikaty IMGW dla pozostałych obszarów Polski.",
|
||||
"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.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}",
|
||||
@@ -259,7 +291,8 @@ const translations = {
|
||||
"warnings.dashboard.viewAll": "Zobacz wszystkie",
|
||||
"hydro.section": "Monitoring wód IMGW",
|
||||
"hydro.title": "Hydro",
|
||||
"hydro.description": "Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
|
||||
"hydro.description":
|
||||
"Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
|
||||
"hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.",
|
||||
"hydro.searchLabel": "Szukaj stacji hydrologicznej",
|
||||
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
|
||||
@@ -272,7 +305,8 @@ const translations = {
|
||||
"hydro.flow": "Przepływ",
|
||||
"hydro.levelMeasurement": "Pomiar poziomu: {date}",
|
||||
"offline.title": "Brak połączenia",
|
||||
"offline.description": "wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
|
||||
"offline.description":
|
||||
"wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
|
||||
"offline.back": "Wróć do aplikacji",
|
||||
},
|
||||
en: {
|
||||
@@ -290,17 +324,21 @@ const translations = {
|
||||
"theme.dark": "Enable dark theme",
|
||||
"settings.section": "Preferences",
|
||||
"settings.title": "Settings",
|
||||
"settings.description": "Manage language, appearance and preparation for IMGW meteorological warning notifications.",
|
||||
"settings.description":
|
||||
"Manage language, appearance and preparation for IMGW meteorological warning notifications.",
|
||||
"settings.language.title": "Language",
|
||||
"settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.",
|
||||
"settings.language.description":
|
||||
"Change the interface language. Station names and IMGW content are not translated automatically.",
|
||||
"settings.theme.title": "Theme",
|
||||
"settings.theme.description": "Switch the light or dark appearance on this device.",
|
||||
"settings.dashboardSections.title": "Home screen",
|
||||
"settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.",
|
||||
"settings.dashboardSections.weatherBrief.title": "Daily brief",
|
||||
"settings.dashboardSections.weatherBrief.description": "Shows the deterministic daily summary and a short forecast for tomorrow.",
|
||||
"settings.dashboardSections.weatherBrief.description":
|
||||
"Shows the deterministic daily summary and a short forecast for tomorrow.",
|
||||
"settings.dashboardSections.featuredStations.title": "Quick select",
|
||||
"settings.dashboardSections.featuredStations.description": "Shows the popular locations list at the bottom of the home screen.",
|
||||
"settings.dashboardSections.featuredStations.description":
|
||||
"Shows the popular locations list at the bottom of the home screen.",
|
||||
"settings.appSections.title": "App sections",
|
||||
"settings.appSections.description": "Choose which additional views are visible in navigation.",
|
||||
"settings.appSections.warnings.title": "Warnings",
|
||||
@@ -308,7 +346,8 @@ const translations = {
|
||||
"settings.appSections.hydro.title": "Hydro",
|
||||
"settings.appSections.hydro.description": "Shows the IMGW hydrological monitoring view in main navigation.",
|
||||
"settings.units.temperature.title": "Temperature unit",
|
||||
"settings.units.temperature.description": "Used in forecasts, briefs, notifications and current readings on this device.",
|
||||
"settings.units.temperature.description":
|
||||
"Used in forecasts, briefs, notifications and current readings on this device.",
|
||||
"settings.units.temperature.label": "Choose temperature unit",
|
||||
"settings.units.temperature.c": "°C",
|
||||
"settings.units.temperature.f": "°F",
|
||||
@@ -319,22 +358,28 @@ const translations = {
|
||||
"settings.units.wind.kmh": "km/h",
|
||||
"settings.units.wind.mph": "mph",
|
||||
"settings.notifications.title": "Weather warning notifications",
|
||||
"settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
|
||||
"settings.notifications.description":
|
||||
"Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
|
||||
"settings.notifications.regionTitle": "Alert area",
|
||||
"settings.notifications.regionDescription": "Current area for future notifications: {province}.",
|
||||
"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.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.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.morningBriefDescription":
|
||||
"Uses the selected location and model forecast to send a short daily summary to this device.",
|
||||
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
|
||||
"settings.notifications.tomorrowBriefDescription": "Sends a short evening forecast for the next day, including storm and rain signals from the model.",
|
||||
"settings.notifications.tomorrowBriefDescription":
|
||||
"Sends a short evening forecast for the next day, including storm and rain signals from the model.",
|
||||
"settings.notifications.enableDevice": "Enable on this device",
|
||||
"settings.notifications.disable": "Disable on this device",
|
||||
"settings.notifications.enabledStatus": "Enabled",
|
||||
@@ -351,11 +396,14 @@ const translations = {
|
||||
"settings.notifications.statusChecking": "Checking notification support in this browser.",
|
||||
"settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.",
|
||||
"settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.",
|
||||
"settings.notifications.statusNeedsInstall": "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
|
||||
"settings.notifications.statusNeedsInstall":
|
||||
"On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
|
||||
"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 and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.",
|
||||
"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 and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.",
|
||||
"pwa.install": "Install",
|
||||
"common.noData": "No data",
|
||||
"common.loading": "Loading data",
|
||||
@@ -379,28 +427,36 @@ const translations = {
|
||||
"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.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.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.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",
|
||||
"location.heroStationFallback": "Fallback data from IMGW station: {station}",
|
||||
"location.heroStationFallbackWithDistance": "Fallback data from IMGW station: {station} · approximately {distance} km away",
|
||||
"location.heroStationFallbackWithDistance":
|
||||
"Fallback data from IMGW station: {station} · approximately {distance} km away",
|
||||
"location.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
|
||||
"location.attribution": "Place search:",
|
||||
"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 and forecast. In Poland it will also match the nearest IMGW station. 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.",
|
||||
"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.",
|
||||
"location.gpsUnavailable": "This browser does not provide GPS location access.",
|
||||
"location.gpsDenied": "Location access was denied. You can change the website permission in your browser settings.",
|
||||
"location.gpsTimeout": "Your location could not be determined in time. Try again.",
|
||||
"location.gpsPositionUnavailable": "Your GPS location could not be determined. Check your device settings and try again.",
|
||||
"location.gpsPositionUnavailable":
|
||||
"Your GPS location could not be determined. Check your device settings and try again.",
|
||||
"location.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.",
|
||||
"location.gpsFallbackName": "Current location",
|
||||
"location.gpsSelected": "Selected location: {location}.",
|
||||
@@ -436,11 +492,13 @@ const translations = {
|
||||
"weather.pressureDetail": "Atmospheric pressure",
|
||||
"weather.windSpeedDetail": "Current IMGW reading",
|
||||
"weather.windDirectionDetail": "Direction the wind is coming from",
|
||||
"weather.rainfallDetail": "Accumulated rainfall total from the IMGW reading. It does not mean that it is raining right now.",
|
||||
"weather.rainfallDetail":
|
||||
"Accumulated rainfall total from the IMGW reading. It does not mean that it is raining right now.",
|
||||
"weather.temperatureDetail": "Air temperature",
|
||||
"forecast.label": "Model forecast",
|
||||
"forecast.title": "Upcoming hours and days",
|
||||
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
|
||||
"forecast.description":
|
||||
"Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
|
||||
"forecast.hourly": "Next 24 hours",
|
||||
"forecast.daily": "7-day forecast",
|
||||
"forecast.today": "Today",
|
||||
@@ -465,7 +523,8 @@ const translations = {
|
||||
"forecast.maxProbability": "Max. rain chance",
|
||||
"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.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 shown as an Open-Meteo model forecast.",
|
||||
"forecast.error": "Unable to load the model forecast.",
|
||||
"forecast.emptyTitle": "Forecast unavailable",
|
||||
@@ -483,7 +542,8 @@ const translations = {
|
||||
"station.all": "All stations",
|
||||
"station.label": "Station {name}",
|
||||
"station.parameters": "Current parameters",
|
||||
"station.parametersDescription": "The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.",
|
||||
"station.parametersDescription":
|
||||
"The latest measurement published by IMGW. Missing values are clearly marked and never replaced with estimates.",
|
||||
"station.error": "Unable to load data for the selected IMGW station.",
|
||||
"station.quality": "Data details",
|
||||
"station.lastMeasurementImgw": "Latest IMGW measurement",
|
||||
@@ -493,12 +553,15 @@ const translations = {
|
||||
"station.publicApi": "Public IMGW API",
|
||||
"snapshot.label": "Measurement snapshot",
|
||||
"snapshot.title": "Current parameter proportions",
|
||||
"snapshot.description": "Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
|
||||
"snapshot.description":
|
||||
"Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
|
||||
"warnings.section": "IMGW notices",
|
||||
"warnings.title": "Warnings",
|
||||
"warnings.description": "Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
|
||||
"warnings.description":
|
||||
"Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
|
||||
"warnings.myProvince": "My area",
|
||||
"warnings.myProvinceDescription": "IMGW notices for {province} are shown first, based on the location selected in weather.",
|
||||
"warnings.myProvinceDescription":
|
||||
"IMGW notices for {province} are shown first, based on the location selected in weather.",
|
||||
"warnings.myProvinceEmptyTitle": "No warnings for your area",
|
||||
"warnings.myProvinceEmptyDescription": "IMGW is not currently publishing active warnings for {province}.",
|
||||
"warnings.otherRegions": "Other regions",
|
||||
@@ -507,7 +570,8 @@ const translations = {
|
||||
"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.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}",
|
||||
@@ -528,7 +592,8 @@ const translations = {
|
||||
"warnings.dashboard.viewAll": "View all",
|
||||
"hydro.section": "IMGW water monitoring",
|
||||
"hydro.title": "Hydro",
|
||||
"hydro.description": "Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
|
||||
"hydro.description":
|
||||
"Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
|
||||
"hydro.error": "Unable to load IMGW hydrological stations.",
|
||||
"hydro.searchLabel": "Search hydrological stations",
|
||||
"hydro.searchPlaceholder": "Search by station, river or province…",
|
||||
@@ -541,7 +606,8 @@ const translations = {
|
||||
"hydro.flow": "Flow",
|
||||
"hydro.levelMeasurement": "Level measurement: {date}",
|
||||
"offline.title": "No connection",
|
||||
"offline.description": "wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
|
||||
"offline.description":
|
||||
"wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
|
||||
"offline.back": "Back to the app",
|
||||
},
|
||||
} as const;
|
||||
@@ -583,12 +649,15 @@ export function I18nProvider({ children }: PropsWithChildren) {
|
||||
setLanguageState(nextLanguage);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({
|
||||
language,
|
||||
locale: language === "pl" ? "pl-PL" : "en-GB",
|
||||
setLanguage,
|
||||
t: (key, params) => translate(language, key, params),
|
||||
}), [language, setLanguage]);
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
language,
|
||||
locale: language === "pl" ? "pl-PL" : "en-GB",
|
||||
setLanguage,
|
||||
t: (key, params) => translate(language, key, params),
|
||||
}),
|
||||
[language, setLanguage],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
normalizeHydroStation,
|
||||
normalizeSynopStation,
|
||||
normalizeWarning,
|
||||
} from "@/lib/weather-utils";
|
||||
import { normalizeHydroStation, normalizeSynopStation, normalizeWarning } from "@/lib/weather-utils";
|
||||
import type {
|
||||
HydroStation,
|
||||
MeteoStationPosition,
|
||||
@@ -66,7 +62,7 @@ export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarnin
|
||||
fetchWarningsByKind("meteo", signal),
|
||||
fetchWarningsByKind("hydro", signal),
|
||||
]);
|
||||
const warnings = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
||||
const warnings = results.flatMap((result) => (result.status === "fulfilled" ? result.value : []));
|
||||
if (results.every((result) => result.status === "rejected")) {
|
||||
throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
|
||||
}
|
||||
|
||||
@@ -53,19 +53,27 @@ function hasNumericValue(value: unknown) {
|
||||
}
|
||||
|
||||
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
|
||||
return hasNumericValue(candidate.Temperature)
|
||||
&& hasNumericValue(candidate.Chill)
|
||||
&& hasNumericValue(candidate.Humidity)
|
||||
&& hasNumericValue(candidate.Wind_Speed)
|
||||
&& hasNumericValue(candidate.PressureMSL);
|
||||
return (
|
||||
hasNumericValue(candidate.Temperature) &&
|
||||
hasNumericValue(candidate.Chill) &&
|
||||
hasNumericValue(candidate.Humidity) &&
|
||||
hasNumericValue(candidate.Wind_Speed) &&
|
||||
hasNumericValue(candidate.PressureMSL)
|
||||
);
|
||||
}
|
||||
|
||||
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
|
||||
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
|
||||
return (
|
||||
hasNumericValue(candidate.Precipitation10m) ||
|
||||
hasNumericValue(candidate.Rain10m) ||
|
||||
hasNumericValue(candidate.Snow10m)
|
||||
);
|
||||
}
|
||||
|
||||
function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
|
||||
const tenMinuteRow = rows.find((candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row));
|
||||
const tenMinuteRow = rows.find(
|
||||
(candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row),
|
||||
);
|
||||
const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
|
||||
if (!tenMinuteRow) return hourlyRow ?? null;
|
||||
if (!hourlyRow) return tenMinuteRow;
|
||||
@@ -74,25 +82,26 @@ function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
|
||||
|
||||
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
|
||||
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow;
|
||||
const precipitationRows = rows.filter((candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row));
|
||||
const precipitationRows = rows.filter(
|
||||
(candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row),
|
||||
);
|
||||
if (!precipitationRows.length) return null;
|
||||
if (!fullRow) return precipitationRows[0];
|
||||
return precipitationRows.reduce((best, candidate) => (
|
||||
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best
|
||||
));
|
||||
return precipitationRows.reduce((best, candidate) =>
|
||||
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
||||
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
||||
|
||||
const rows = payload.data.Data
|
||||
.flatMap((candidate): NormalizedHybridRow[] => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as RawImgwHybridWeatherRow;
|
||||
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
||||
const normalizedRow = normalizeHybridRow(row);
|
||||
return normalizedRow ? [normalizedRow] : [];
|
||||
});
|
||||
const rows = payload.data.Data.flatMap((candidate): NormalizedHybridRow[] => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as RawImgwHybridWeatherRow;
|
||||
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
||||
const normalizedRow = normalizeHybridRow(row);
|
||||
return normalizedRow ? [normalizedRow] : [];
|
||||
});
|
||||
const fullRow = pickFullWeatherRow(rows);
|
||||
const precipitationRow = pickPrecipitationRow(rows, fullRow);
|
||||
const row = fullRow ?? precipitationRow;
|
||||
@@ -131,12 +140,17 @@ export async function fetchImgwCurrentWeather(latitude: number, longitude: numbe
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
||||
const response = await fetch(`/api/imgw-current?${params}`, { signal });
|
||||
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
|
||||
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse);
|
||||
return normalizeImgwCurrentWeather((await response.json()) as RawImgwHybridWeatherResponse);
|
||||
}
|
||||
|
||||
export async function fetchCurrentWeather(latitude: number, longitude: number, region: WeatherRegion, signal?: AbortSignal) {
|
||||
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;
|
||||
return (await response.json()) as ImgwCurrentWeather | null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export function isImgwNoProductsResponse(value: unknown) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const response = value as { status?: unknown; message?: unknown };
|
||||
return response.status === false
|
||||
&& typeof response.message === "string"
|
||||
&& response.message.toLocaleLowerCase("en-US").includes("no products were found");
|
||||
return (
|
||||
response.status === false &&
|
||||
typeof response.message === "string" &&
|
||||
response.message.toLocaleLowerCase("en-US").includes("no products were found")
|
||||
);
|
||||
}
|
||||
|
||||
export async function readImgwResponseBody(response: Response) {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { LocationSearchResult, ReverseLocationResult } from "@/types/location";
|
||||
|
||||
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> {
|
||||
export async function fetchLocations(
|
||||
query: string,
|
||||
language: Language,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LocationSearchResult[]> {
|
||||
const params = new URLSearchParams({ query, language });
|
||||
const response = await fetch(`/api/locations/search?${params}`, { signal });
|
||||
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
|
||||
return response.json() as Promise<LocationSearchResult[]>;
|
||||
}
|
||||
|
||||
export async function fetchReverseLocation(latitude: number, longitude: number, language: Language): Promise<ReverseLocationResult> {
|
||||
export async function fetchReverseLocation(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
language: Language,
|
||||
): Promise<ReverseLocationResult> {
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language });
|
||||
const response = await fetch(`/api/locations/reverse?${params}`);
|
||||
if (!response.ok) throw new Error("Reverse location search is temporarily unavailable.");
|
||||
|
||||
@@ -30,10 +30,11 @@ function normalizeName(value: string) {
|
||||
|
||||
function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
|
||||
const earthRadiusKm = 6371;
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const toRadians = (value: number) => (value * Math.PI) / 180;
|
||||
const latitudeDistance = toRadians(latitudeB - latitudeA);
|
||||
const longitudeDistance = toRadians(longitudeB - longitudeA);
|
||||
const a = Math.sin(latitudeDistance / 2) ** 2 +
|
||||
const a =
|
||||
Math.sin(latitudeDistance / 2) ** 2 +
|
||||
Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2;
|
||||
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -81,17 +82,31 @@ export function findNearestSynopStation(
|
||||
}
|
||||
|
||||
export function createSelectedLocation(
|
||||
location: Pick<LocationSearchResult, "name" | "province" | "district" | "country" | "countryCode" | "admin1" | "admin2" | "timezone" | "region" | "latitude" | "longitude">,
|
||||
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;
|
||||
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,
|
||||
@@ -103,7 +118,8 @@ export function createSelectedLocation(
|
||||
admin2: location.admin2,
|
||||
timezone: location.timezone,
|
||||
region,
|
||||
countyTeryt: region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null,
|
||||
countyTeryt:
|
||||
region === "PL" ? getCountyTerytForLocation(location.province, location.district, location.name) : null,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
stationId: nearest?.station.id ?? null,
|
||||
|
||||
@@ -28,7 +28,12 @@ interface SavePushSubscriptionOptions {
|
||||
windSpeedUnit?: WindSpeedUnit;
|
||||
}
|
||||
|
||||
export async function savePushSubscription(subscription: PushSubscription, province: Province | null, 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" },
|
||||
|
||||
@@ -85,22 +85,22 @@ const provinceByStationId: Record<string, Province> = {
|
||||
};
|
||||
|
||||
const provinceLabels: Record<Province, Record<Language, string>> = {
|
||||
"dolnośląskie": { pl: "dolnośląskie", en: "Lower Silesian" },
|
||||
dolnośląskie: { pl: "dolnośląskie", en: "Lower Silesian" },
|
||||
"kujawsko-pomorskie": { pl: "kujawsko-pomorskie", en: "Kuyavian-Pomeranian" },
|
||||
"lubelskie": { pl: "lubelskie", en: "Lublin" },
|
||||
"lubuskie": { pl: "lubuskie", en: "Lubusz" },
|
||||
"łódzkie": { pl: "łódzkie", en: "Łódź" },
|
||||
"małopolskie": { pl: "małopolskie", en: "Lesser Poland" },
|
||||
"mazowieckie": { pl: "mazowieckie", en: "Masovian" },
|
||||
"opolskie": { pl: "opolskie", en: "Opole" },
|
||||
"podkarpackie": { pl: "podkarpackie", en: "Subcarpathian" },
|
||||
"podlaskie": { pl: "podlaskie", en: "Podlaskie" },
|
||||
"pomorskie": { pl: "pomorskie", en: "Pomeranian" },
|
||||
"śląskie": { pl: "śląskie", en: "Silesian" },
|
||||
"świętokrzyskie": { pl: "świętokrzyskie", en: "Świętokrzyskie" },
|
||||
lubelskie: { pl: "lubelskie", en: "Lublin" },
|
||||
lubuskie: { pl: "lubuskie", en: "Lubusz" },
|
||||
łódzkie: { pl: "łódzkie", en: "Łódź" },
|
||||
małopolskie: { pl: "małopolskie", en: "Lesser Poland" },
|
||||
mazowieckie: { pl: "mazowieckie", en: "Masovian" },
|
||||
opolskie: { pl: "opolskie", en: "Opole" },
|
||||
podkarpackie: { pl: "podkarpackie", en: "Subcarpathian" },
|
||||
podlaskie: { pl: "podlaskie", en: "Podlaskie" },
|
||||
pomorskie: { pl: "pomorskie", en: "Pomeranian" },
|
||||
śląskie: { pl: "śląskie", en: "Silesian" },
|
||||
świętokrzyskie: { pl: "świętokrzyskie", en: "Świętokrzyskie" },
|
||||
"warmińsko-mazurskie": { pl: "warmińsko-mazurskie", en: "Warmian-Masurian" },
|
||||
"wielkopolskie": { pl: "wielkopolskie", en: "Greater Poland" },
|
||||
"zachodniopomorskie": { pl: "zachodniopomorskie", en: "West Pomeranian" },
|
||||
wielkopolskie: { pl: "wielkopolskie", en: "Greater Poland" },
|
||||
zachodniopomorskie: { pl: "zachodniopomorskie", en: "West Pomeranian" },
|
||||
};
|
||||
|
||||
export const PROVINCES = Object.keys(provinceLabels) as Province[];
|
||||
@@ -123,11 +123,11 @@ export function getProvinceFromTeryt(code: string) {
|
||||
}
|
||||
|
||||
export function getProvinceForStation(stationId: string | null) {
|
||||
return stationId ? provinceByStationId[stationId] ?? null : null;
|
||||
return stationId ? (provinceByStationId[stationId] ?? null) : null;
|
||||
}
|
||||
|
||||
export function normalizeProvinceName(value: string | null | undefined) {
|
||||
return value ? provinceBySimplifiedName[simplifyProvinceName(value)] ?? null : null;
|
||||
return value ? (provinceBySimplifiedName[simplifyProvinceName(value)] ?? null) : null;
|
||||
}
|
||||
|
||||
export function getProvinceForSelection(locationProvince: string | null | undefined, stationId: string | null) {
|
||||
|
||||
@@ -49,13 +49,20 @@ function formatWarningValidity(warning: WeatherWarning, language: Language) {
|
||||
}
|
||||
|
||||
function buildWarningPayload(preference: WarningPushSubscription, warning: WeatherWarning) {
|
||||
const region = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "wtr.";
|
||||
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"
|
||||
? [`${region}: ${level}`, validity, warning.probability !== null ? `prawdopodobieństwo ${warning.probability}%` : null]
|
||||
: [`${region}: ${level}`, validity, warning.probability !== null ? `${warning.probability}% probability` : null];
|
||||
const bodyParts =
|
||||
preference.language === "pl"
|
||||
? [
|
||||
`${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 +80,15 @@ export async function sendWarningNotification(preference: WarningPushSubscriptio
|
||||
|
||||
export async function sendTestNotification(preference: WarningPushSubscription) {
|
||||
ensureWebPushConfigured();
|
||||
const location = preference.province ? formatProvinceName(preference.province, preference.language) : preference.locationName ?? "Open-Meteo";
|
||||
const location = preference.province
|
||||
? formatProvinceName(preference.province, preference.language)
|
||||
: (preference.locationName ?? "Open-Meteo");
|
||||
const payload = JSON.stringify({
|
||||
title: preference.language === "pl" ? "wtr.: test powiadomień" : "wtr.: notification test",
|
||||
body: preference.language === "pl"
|
||||
? `Powiadomienia są aktywne dla: ${location}.`
|
||||
: `Notifications are active for: ${location}.`,
|
||||
body:
|
||||
preference.language === "pl"
|
||||
? `Powiadomienia są aktywne dla: ${location}.`
|
||||
: `Notifications are active for: ${location}.`,
|
||||
url: "/settings",
|
||||
});
|
||||
await webPush.sendNotification(preference.subscription as PushSubscription, payload);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||
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";
|
||||
@@ -121,7 +126,7 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
|
||||
return {
|
||||
endpoint: row.endpoint,
|
||||
subscription,
|
||||
province: row.province === "global" ? null : row.province as Province,
|
||||
province: row.province === "global" ? null : (row.province as Province),
|
||||
region,
|
||||
language: row.language === "en" ? "en" : "pl",
|
||||
enabled: row.enabled === 1,
|
||||
@@ -143,7 +148,9 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
|
||||
}
|
||||
|
||||
export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
getDatabase().prepare(`
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO push_subscriptions (
|
||||
endpoint,
|
||||
subscription_json,
|
||||
@@ -197,25 +204,27 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
|
||||
temperature_unit = excluded.temperature_unit,
|
||||
wind_speed_unit = excluded.wind_speed_unit,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({
|
||||
endpoint: subscription.endpoint,
|
||||
subscriptionJson: JSON.stringify(subscription.subscription),
|
||||
province: subscription.province ?? "global",
|
||||
region: subscription.region,
|
||||
language: subscription.language,
|
||||
enabled: subscription.enabled ? 1 : 0,
|
||||
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
||||
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
|
||||
latitude: subscription.latitude,
|
||||
longitude: subscription.longitude,
|
||||
locationName: subscription.locationName,
|
||||
timezone: subscription.timezone,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
temperatureUnit: subscription.temperatureUnit,
|
||||
windSpeedUnit: subscription.windSpeedUnit,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
});
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
endpoint: subscription.endpoint,
|
||||
subscriptionJson: JSON.stringify(subscription.subscription),
|
||||
province: subscription.province ?? "global",
|
||||
region: subscription.region,
|
||||
language: subscription.language,
|
||||
enabled: subscription.enabled ? 1 : 0,
|
||||
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
|
||||
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
|
||||
latitude: subscription.latitude,
|
||||
longitude: subscription.longitude,
|
||||
locationName: subscription.locationName,
|
||||
timezone: subscription.timezone,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
temperatureUnit: subscription.temperatureUnit,
|
||||
windSpeedUnit: subscription.windSpeedUnit,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function removePushSubscription(endpoint: string) {
|
||||
|
||||
@@ -26,10 +26,21 @@ interface RawOpenMeteoCurrentResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenMeteoCondition(weatherCode: number | null, rain: number | null, showers: number | null, snowfall: number | null): CurrentWeatherCondition {
|
||||
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";
|
||||
if (
|
||||
(rain ?? 0) > 0 ||
|
||||
(showers ?? 0) > 0 ||
|
||||
(weatherCode !== null && weatherCode >= 51 && weatherCode <= 67) ||
|
||||
(weatherCode !== null && weatherCode >= 80 && weatherCode <= 82)
|
||||
)
|
||||
return "rain";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,7 +87,7 @@ export async function fetchImgwHybridCurrentWeather(latitude: number, longitude:
|
||||
});
|
||||
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;
|
||||
return (await response.json()) as RawImgwHybridWeatherResponse;
|
||||
}
|
||||
|
||||
export async function fetchServerCurrentWeather(latitude: number, longitude: number, region: WeatherRegion) {
|
||||
@@ -109,5 +120,5 @@ export async function fetchServerCurrentWeather(latitude: number, longitude: num
|
||||
});
|
||||
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);
|
||||
return normalizeOpenMeteoCurrent((await response.json()) as RawOpenMeteoCurrentResponse);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function parseForecastCoordinate(value: string | null, min: number, max:
|
||||
async function readImgwPayload(response: Response | null) {
|
||||
if (!response?.ok) return null;
|
||||
try {
|
||||
return await response.json() as RawImgwForecastResponse;
|
||||
return (await response.json()) as RawImgwForecastResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export async function fetchServerForecast(latitude: number, longitude: number, r
|
||||
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",
|
||||
daily:
|
||||
"weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
|
||||
timezone: region === "PL" ? "Europe/Warsaw" : "auto",
|
||||
forecast_days: "7",
|
||||
wind_speed_unit: "ms",
|
||||
@@ -42,14 +43,20 @@ export async function fetchServerForecast(latitude: number, longitude: number, r
|
||||
});
|
||||
|
||||
const [openMeteoResponse, imgwResponse] = await Promise.all([
|
||||
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS) }),
|
||||
fetch(`${OPEN_METEO_FORECAST_URL}?${openMeteoParams}`, {
|
||||
next: { revalidate: 900 },
|
||||
signal: AbortSignal.timeout(OPEN_METEO_TIMEOUT_MS),
|
||||
}),
|
||||
region === "PL"
|
||||
? fetch(`${IMGW_FORECAST_URL}?${imgwParams}`, { next: { revalidate: 900 }, signal: AbortSignal.timeout(IMGW_TIMEOUT_MS) }).catch(() => null)
|
||||
? 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 openMeteoPayload = (await openMeteoResponse.json()) as RawWeatherForecast;
|
||||
const imgwPayload = await readImgwPayload(imgwResponse);
|
||||
return mergeForecastSources(openMeteoPayload, imgwPayload, region);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ export async function fetchMeteoWarnings(signal?: AbortSignal): Promise<WeatherW
|
||||
if (!response.ok) {
|
||||
const details = await readImgwResponseBody(response);
|
||||
if (response.status === 404 && isImgwNoProductsResponse(details.json)) return [];
|
||||
throw new Error(`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`);
|
||||
throw new Error(
|
||||
`Unable to load IMGW meteorological warnings: ${response.status}${details.text ? ` ${details.text.slice(0, 240)}` : ""}`,
|
||||
);
|
||||
}
|
||||
const rows = await response.json() as RawWarning[];
|
||||
const rows = (await response.json()) as RawWarning[];
|
||||
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, "meteo", index)) : [];
|
||||
}
|
||||
|
||||
33
lib/store.ts
33
lib/store.ts
@@ -5,8 +5,18 @@ import { persist } from "zustand/middleware";
|
||||
import type { SelectedLocation } from "@/types/location";
|
||||
import type { Province } from "@/types/province";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
import { DEFAULT_APP_SECTION_VISIBILITY, normalizeAppSectionVisibility, type AppSectionId, type AppSectionVisibility } from "@/lib/app-sections";
|
||||
import { DEFAULT_DASHBOARD_SECTION_VISIBILITY, normalizeDashboardSectionVisibility, type DashboardSectionId, type DashboardSectionVisibility } from "@/lib/dashboard-sections";
|
||||
import {
|
||||
DEFAULT_APP_SECTION_VISIBILITY,
|
||||
normalizeAppSectionVisibility,
|
||||
type AppSectionId,
|
||||
type AppSectionVisibility,
|
||||
} from "@/lib/app-sections";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_SECTION_VISIBILITY,
|
||||
normalizeDashboardSectionVisibility,
|
||||
type DashboardSectionId,
|
||||
type DashboardSectionVisibility,
|
||||
} from "@/lib/dashboard-sections";
|
||||
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";
|
||||
@@ -70,18 +80,23 @@ export const useWeatherStore = create<WeatherStore>()(
|
||||
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
|
||||
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
|
||||
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
|
||||
setDashboardSectionVisible: (section, visible) => set((state) => ({
|
||||
dashboardSections: { ...state.dashboardSections, [section]: visible },
|
||||
})),
|
||||
setAppSectionVisible: (section, visible) => set((state) => ({
|
||||
appSections: { ...state.appSections, [section]: visible },
|
||||
})),
|
||||
setDashboardSectionVisible: (section, visible) =>
|
||||
set((state) => ({
|
||||
dashboardSections: { ...state.dashboardSections, [section]: visible },
|
||||
})),
|
||||
setAppSectionVisible: (section, visible) =>
|
||||
set((state) => ({
|
||||
appSections: { ...state.appSections, [section]: visible },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "wtr:preferences",
|
||||
migrate: (persisted) => {
|
||||
const state = persisted as Partial<WeatherStore> | undefined;
|
||||
const location = state?.selectedLocation as (Partial<SelectedLocation> & { region?: WeatherRegion }) | null | undefined;
|
||||
const location = state?.selectedLocation as
|
||||
| (Partial<SelectedLocation> & { region?: WeatherRegion })
|
||||
| null
|
||||
| undefined;
|
||||
if (!state) return persisted;
|
||||
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
|
||||
const appSections = normalizeAppSectionVisibility(state.appSections);
|
||||
|
||||
@@ -50,19 +50,21 @@ const mazowieckieCountyTerytByName: Record<string, string> = {
|
||||
};
|
||||
|
||||
function normalizeRegionName(value: string | null | undefined) {
|
||||
return value
|
||||
?.replace(/[Łł]/g, "l")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase("pl-PL")
|
||||
.replace(/^powiat\s+/, "")
|
||||
.replace(/^m\.\s*/, "")
|
||||
.replace(/^miasto\s+/, "")
|
||||
.replace(/\s+powiat$/, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
|
||||
.trim() ?? "";
|
||||
return (
|
||||
value
|
||||
?.replace(/[Łł]/g, "l")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase("pl-PL")
|
||||
.replace(/^powiat\s+/, "")
|
||||
.replace(/^m\.\s*/, "")
|
||||
.replace(/^miasto\s+/, "")
|
||||
.replace(/\s+powiat$/, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
.replace(/^warszawa_zachodnia$/, "warszawski_zachodni")
|
||||
.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTerytCountyCode(value: string) {
|
||||
@@ -70,7 +72,11 @@ function normalizeTerytCountyCode(value: string) {
|
||||
return /^\d{4}/.test(code) ? code.slice(0, 4) : null;
|
||||
}
|
||||
|
||||
export function getCountyTerytForLocation(province: string | null | undefined, district: string | null | undefined, locationName: string | null | undefined) {
|
||||
export function getCountyTerytForLocation(
|
||||
province: string | null | undefined,
|
||||
district: string | null | undefined,
|
||||
locationName: string | null | undefined,
|
||||
) {
|
||||
const normalizedProvince = getProvinceForSelection(province, null);
|
||||
if (normalizedProvince !== "mazowieckie") return null;
|
||||
|
||||
@@ -79,7 +85,7 @@ export function getCountyTerytForLocation(province: string | null | undefined, d
|
||||
if (districtMatch) return districtMatch;
|
||||
|
||||
const normalizedLocation = normalizeRegionName(locationName);
|
||||
return normalizedLocation ? mazowieckieCountyTerytByName[normalizedLocation] ?? null : null;
|
||||
return normalizedLocation ? (mazowieckieCountyTerytByName[normalizedLocation] ?? null) : null;
|
||||
}
|
||||
|
||||
export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: string | null | undefined) {
|
||||
@@ -87,7 +93,11 @@ export function warningMatchesCounty(warning: WeatherWarning, countyTeryt: strin
|
||||
return warning.terytCodes.some((code) => normalizeTerytCountyCode(code) === countyTeryt);
|
||||
}
|
||||
|
||||
export function warningMatchesLocalSelection(warning: WeatherWarning, province: Province | null, selectedLocation?: SelectedLocation | null) {
|
||||
export function warningMatchesLocalSelection(
|
||||
warning: WeatherWarning,
|
||||
province: Province | null,
|
||||
selectedLocation?: SelectedLocation | null,
|
||||
) {
|
||||
if (warning.kind === "meteo" && selectedLocation?.countyTeryt) {
|
||||
return warningMatchesCounty(warning, selectedLocation.countyTeryt);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
|
||||
import type { WeatherWarning } from "@/types/imgw";
|
||||
import type { Province } from "@/types/province";
|
||||
import { warningMatchesCounty } from "@/lib/warning-regions";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, formatTemperature as formatDisplayTemperature, formatWindSpeed } from "@/lib/weather-utils";
|
||||
import {
|
||||
DEFAULT_TEMPERATURE_UNIT,
|
||||
DEFAULT_WIND_SPEED_UNIT,
|
||||
formatTemperature as formatDisplayTemperature,
|
||||
formatWindSpeed,
|
||||
} from "@/lib/weather-utils";
|
||||
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
||||
|
||||
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
|
||||
@@ -74,14 +79,17 @@ function getWarsawDateKey(date: Date, dayOffset = 0) {
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
|
||||
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
|
||||
const shiftedDate = new Date(
|
||||
Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12),
|
||||
);
|
||||
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(shiftedDate);
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
|
||||
}
|
||||
|
||||
@@ -98,7 +106,9 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
|
||||
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 hourTimestamp === null || currentTimestamp === null
|
||||
? hour.time >= currentHour
|
||||
: hourTimestamp >= currentTimestamp;
|
||||
});
|
||||
return upcoming.slice(0, limit);
|
||||
}
|
||||
@@ -135,11 +145,18 @@ function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) =
|
||||
}, null);
|
||||
}
|
||||
|
||||
function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, now: number) {
|
||||
function isRelevantMeteoWarning(
|
||||
warning: WeatherWarning,
|
||||
province: Province | null,
|
||||
countyTeryt: string | null | undefined,
|
||||
now: number,
|
||||
) {
|
||||
const validTo = getTimestamp(warning.validTo);
|
||||
return warning.kind === "meteo"
|
||||
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
|
||||
&& (validTo === null || validTo > now);
|
||||
return (
|
||||
warning.kind === "meteo" &&
|
||||
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
|
||||
(validTo === null || validTo > now)
|
||||
);
|
||||
}
|
||||
|
||||
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
|
||||
@@ -148,10 +165,17 @@ function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
|
||||
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
|
||||
}
|
||||
|
||||
function isRelevantMeteoWarningForDate(warning: WeatherWarning, province: Province | null, countyTeryt: string | null | undefined, dateKey: string) {
|
||||
return warning.kind === "meteo"
|
||||
&& (countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province))
|
||||
&& warningOverlapsWarsawDate(warning, dateKey);
|
||||
function isRelevantMeteoWarningForDate(
|
||||
warning: WeatherWarning,
|
||||
province: Province | null,
|
||||
countyTeryt: string | null | undefined,
|
||||
dateKey: string,
|
||||
) {
|
||||
return (
|
||||
warning.kind === "meteo" &&
|
||||
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
|
||||
warningOverlapsWarsawDate(warning, dateKey)
|
||||
);
|
||||
}
|
||||
|
||||
function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
|
||||
@@ -164,7 +188,12 @@ function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => bo
|
||||
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode));
|
||||
}
|
||||
|
||||
function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | null, maximumProbability: number | null, language: Language) {
|
||||
function getTomorrowCondition(
|
||||
hours: HourlyForecast[],
|
||||
rainfallTotal: number | null,
|
||||
maximumProbability: number | null,
|
||||
language: Language,
|
||||
) {
|
||||
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
|
||||
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
|
||||
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
|
||||
@@ -199,7 +228,17 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n
|
||||
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
|
||||
}
|
||||
|
||||
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
export function buildWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
province,
|
||||
countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
now = new Date(),
|
||||
}: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
const hours = getUpcomingHours(forecast.hourly, now, 24);
|
||||
if (!hours.length) return null;
|
||||
|
||||
@@ -214,12 +253,17 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
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
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: null;
|
||||
const temperatureRange =
|
||||
minimumTemperature !== null && maximumTemperature !== null
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: 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 severity: WeatherBriefSeverity = topWarning
|
||||
? "warning"
|
||||
: hasRainSignal || hasWindSignal
|
||||
? "attention"
|
||||
: "normal";
|
||||
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
||||
|
||||
const headline = topWarning
|
||||
@@ -241,45 +285,71 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
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}`);
|
||||
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}.`);
|
||||
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}`);
|
||||
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 ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
|
||||
);
|
||||
}
|
||||
if (conditionPeakHour) {
|
||||
body.push(language === "pl"
|
||||
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
|
||||
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`);
|
||||
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 ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
|
||||
maximumProbability !== null
|
||||
? language === "pl"
|
||||
? `opad do ${maximumProbability}%`
|
||||
: `rain up to ${maximumProbability}%`
|
||||
: null,
|
||||
maximumWind !== null
|
||||
? language === "pl"
|
||||
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const warningPrefix = topWarning
|
||||
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
|
||||
? language === "pl"
|
||||
? `IMGW: ${topWarning.title || "ostrzeżenie"}. `
|
||||
: `IMGW: ${topWarning.title || "warning"}. `
|
||||
: "";
|
||||
|
||||
return {
|
||||
@@ -292,7 +362,17 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, temperatureUnit = DEFAULT_TEMPERATURE_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
export function buildTomorrowWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
province,
|
||||
countyTeryt,
|
||||
locationName,
|
||||
language,
|
||||
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
now = new Date(),
|
||||
}: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
const targetDateKey = getWarsawDateKey(now, 1);
|
||||
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
|
||||
if (!hours.length) return null;
|
||||
@@ -308,16 +388,19 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
|
||||
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
||||
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
|
||||
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
|
||||
const hasRainSignal = hasThunderstorm
|
||||
|| (rainfallTotal ?? 0) >= 0.5
|
||||
|| (maximumProbability ?? 0) >= 35
|
||||
|| hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
|
||||
const hasRainSignal =
|
||||
hasThunderstorm ||
|
||||
(rainfallTotal ?? 0) >= 0.5 ||
|
||||
(maximumProbability ?? 0) >= 35 ||
|
||||
hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
|
||||
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
||||
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
|
||||
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: null;
|
||||
const severity: WeatherBriefSeverity = topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||
const temperatureRange =
|
||||
minimumTemperature !== null && maximumTemperature !== null
|
||||
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
|
||||
: null;
|
||||
const severity: WeatherBriefSeverity =
|
||||
topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
||||
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
||||
|
||||
const headline = topWarning
|
||||
@@ -343,49 +426,66 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
|
||||
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}`);
|
||||
const probability =
|
||||
topWarning.probability !== null
|
||||
? language === "pl"
|
||||
? ` Prawdopodobieństwo: ${topWarning.probability}%.`
|
||||
: ` Probability: ${topWarning.probability}%.`
|
||||
: "";
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
|
||||
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`,
|
||||
);
|
||||
}
|
||||
if (temperatureRange) {
|
||||
body.push(language === "pl"
|
||||
? `Temperatura jutro: ${temperatureRange}.`
|
||||
: `Temperature tomorrow: ${temperatureRange}.`);
|
||||
body.push(
|
||||
language === "pl" ? `Temperatura jutro: ${temperatureRange}.` : `Temperature tomorrow: ${temperatureRange}.`,
|
||||
);
|
||||
}
|
||||
body.push(language === "pl"
|
||||
? `Sygnał modelu: ${condition}.`
|
||||
: `Model signal: ${condition}.`);
|
||||
body.push(language === "pl" ? `Sygnał modelu: ${condition}.` : `Model signal: ${condition}.`);
|
||||
if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
|
||||
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
||||
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
||||
: "";
|
||||
body.push(language === "pl"
|
||||
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
||||
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
|
||||
const rainWindow =
|
||||
rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
||||
? language === "pl"
|
||||
? ` Największa szansa około ${formatHour(rainPeakHour.time)}.`
|
||||
: ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
||||
: "";
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
||||
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
|
||||
);
|
||||
} else {
|
||||
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
|
||||
}
|
||||
if (maximumWind !== null) {
|
||||
body.push(language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
|
||||
body.push(
|
||||
language === "pl"
|
||||
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
|
||||
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatRainfall(rainfallTotal, language)
|
||||
: null;
|
||||
const rainPushPart =
|
||||
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatRainfall(rainfallTotal, language)
|
||||
: null;
|
||||
const pushParts = [
|
||||
`${locationName}:`,
|
||||
temperatureRange,
|
||||
condition,
|
||||
rainPushPart,
|
||||
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
|
||||
maximumWind !== null
|
||||
? language === "pl"
|
||||
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const warningPrefix = topWarning
|
||||
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
|
||||
? language === "pl"
|
||||
? `IMGW: ${topWarning.title || "ostrzeżenie"}. `
|
||||
: `IMGW: ${topWarning.title || "warning"}. `
|
||||
: "";
|
||||
|
||||
return {
|
||||
|
||||
@@ -85,10 +85,14 @@ export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null
|
||||
}
|
||||
|
||||
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
|
||||
const provinces = [...new Set([
|
||||
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
|
||||
...(raw.teryt ?? []).map(getProvinceFromTeryt),
|
||||
].filter((province) => province !== null))];
|
||||
const provinces = [
|
||||
...new Set(
|
||||
[
|
||||
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
|
||||
...(raw.teryt ?? []).map(getProvinceFromTeryt),
|
||||
].filter((province) => province !== null),
|
||||
),
|
||||
];
|
||||
const describedAreas = (raw.obszary ?? [])
|
||||
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
||||
.filter((area): area is string => Boolean(area));
|
||||
@@ -106,7 +110,9 @@ export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: numb
|
||||
publishedAt: normalizeDate(raw.opublikowano),
|
||||
probability: toNumber(raw.prawdopodobienstwo),
|
||||
areas,
|
||||
terytCodes: Array.isArray(raw.teryt) ? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0) : [],
|
||||
terytCodes: Array.isArray(raw.teryt)
|
||||
? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0)
|
||||
: [],
|
||||
provinces,
|
||||
office: raw.biuro?.trim() || null,
|
||||
};
|
||||
@@ -125,15 +131,25 @@ export function convertTemperature(value: number, unit: TemperatureUnit) {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatTemperatureValue(value: number, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
|
||||
export function formatTemperatureValue(
|
||||
value: number,
|
||||
language: Language = "pl",
|
||||
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
) {
|
||||
const formattedValue = new Intl.NumberFormat(locales[language], {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
return `${formattedValue}${getTemperatureUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function formatTemperature(value: number | null, language: Language = "pl", unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT) {
|
||||
return value === null ? translate(language, "common.noData") : formatTemperatureValue(convertTemperature(value, unit), language, unit);
|
||||
export function formatTemperature(
|
||||
value: number | null,
|
||||
language: Language = "pl",
|
||||
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
||||
) {
|
||||
return value === null
|
||||
? translate(language, "common.noData")
|
||||
: formatTemperatureValue(convertTemperature(value, unit), language, unit);
|
||||
}
|
||||
|
||||
export function formatPressure(value: number | null, language: Language = "pl") {
|
||||
@@ -158,7 +174,11 @@ export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
|
||||
return speed;
|
||||
}
|
||||
|
||||
export function formatWindSpeed(speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
|
||||
export function formatWindSpeed(
|
||||
speed: number | null,
|
||||
language: Language = "pl",
|
||||
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
) {
|
||||
if (speed === null) return translate(language, "common.noData");
|
||||
const convertedSpeed = convertWindSpeed(speed, unit);
|
||||
const digits = unit === "ms" ? 1 : 0;
|
||||
@@ -169,7 +189,12 @@ export function formatWindSpeed(speed: number | null, language: Language = "pl",
|
||||
return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`;
|
||||
}
|
||||
|
||||
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
|
||||
export function formatWind(
|
||||
speed: number | null,
|
||||
direction?: number | null,
|
||||
language: Language = "pl",
|
||||
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
||||
) {
|
||||
if (speed === null) return translate(language, "common.noData");
|
||||
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
||||
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
|
||||
@@ -187,7 +212,11 @@ export function formatFlow(value: number | null, language: Language = "pl") {
|
||||
return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null, language: Language = "pl", fallback = translate(language, "common.noData")) {
|
||||
export function formatDateTime(
|
||||
value: string | null,
|
||||
language: Language = "pl",
|
||||
fallback = translate(language, "common.noData"),
|
||||
) {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
@@ -215,9 +244,17 @@ export function calculateFeelsLike(temperature: number | null, humidity: number
|
||||
const c7 = 0.002211732;
|
||||
const c8 = 0.00072546;
|
||||
const c9 = -0.000003582;
|
||||
return c1 + c2 * temperature + c3 * humidity + c4 * temperature * humidity + c5 * temperature ** 2 +
|
||||
c6 * humidity ** 2 + c7 * temperature ** 2 * humidity + c8 * temperature * humidity ** 2 +
|
||||
c9 * temperature ** 2 * humidity ** 2;
|
||||
return (
|
||||
c1 +
|
||||
c2 * temperature +
|
||||
c3 * humidity +
|
||||
c4 * temperature * humidity +
|
||||
c5 * temperature ** 2 +
|
||||
c6 * humidity ** 2 +
|
||||
c7 * temperature ** 2 * humidity +
|
||||
c8 * temperature * humidity ** 2 +
|
||||
c9 * temperature ** 2 * humidity ** 2
|
||||
);
|
||||
}
|
||||
return temperature;
|
||||
}
|
||||
@@ -243,8 +280,10 @@ function getForecastConditionDescription(code: number | null, language: Language
|
||||
if (code === 3) return translate(language, "forecast.condition.cloudy");
|
||||
if (code === 45 || code === 48) return translate(language, "forecast.condition.fog");
|
||||
if (code !== null && code >= 51 && code <= 57) return translate(language, "forecast.condition.drizzle");
|
||||
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))) return translate(language, "forecast.condition.rain");
|
||||
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)) return translate(language, "forecast.condition.snow");
|
||||
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)))
|
||||
return translate(language, "forecast.condition.rain");
|
||||
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86))
|
||||
return translate(language, "forecast.condition.snow");
|
||||
if (code !== null && code >= 95) return translate(language, "forecast.condition.thunderstorm");
|
||||
return null;
|
||||
}
|
||||
@@ -270,7 +309,11 @@ export function getWeatherDescription(
|
||||
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
|
||||
const currentDescription = getForecastConditionDescription(currentWeatherCode ?? null, language);
|
||||
const cloudCoverDescription = getCloudCoverDescription(currentCloudCover, language);
|
||||
if (cloudCoverDescription && (currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)) return cloudCoverDescription;
|
||||
if (
|
||||
cloudCoverDescription &&
|
||||
(currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)
|
||||
)
|
||||
return cloudCoverDescription;
|
||||
if (currentDescription) return currentDescription;
|
||||
if (cloudCoverDescription) return cloudCoverDescription;
|
||||
const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language);
|
||||
|
||||
Reference in New Issue
Block a user