diff --git a/AGENTS.md b/AGENTS.md
index 53b0ebe..09ac84e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,7 +54,7 @@ Testy jednostkowe uruchamia `npm run test` przez Vitest, a `npm run test:watch`
- Listy ostrzeżeń zachowują priorytet lokalnego obszaru, a wewnątrz każdej grupy pokazują ostrzeżenia meteorologiczne przed hydrologicznymi. Jeśli lokalizacja ma rozpoznany powiat TERYT, ostrzeżenia meteo filtruj po tym powiecie; w przeciwnym razie stosuj fallback wojewódzki. W obrębie rodzaju zachowuj kolejność publikacji od najnowszych.
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i, dla `PL`, ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW.
-- Preferencje jednostek temperatury i wiatru są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI.
+- Preferencje jednostek temperatury, wiatru, opadu, ciśnienia i dystansu są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI.
- Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Briefy są deduplikowane po lokalnej dacie subskrypcji i wysyłane po godzinie skonfigurowanej względem zapisanej strefy czasowej lokalizacji. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`.
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
diff --git a/app/api/notifications/daily-brief/route.ts b/app/api/notifications/daily-brief/route.ts
index f650648..b2dd92c 100644
--- a/app/api/notifications/daily-brief/route.ts
+++ b/app/api/notifications/daily-brief/route.ts
@@ -9,7 +9,7 @@ import {
} from "@/lib/push-store";
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
import { buildWeatherBrief } from "@/lib/weather-brief";
-import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
+import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
export const runtime = "nodejs";
@@ -113,6 +113,7 @@ export async function GET(request: Request) {
language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now,
});
if (!brief) {
diff --git a/app/api/notifications/subscriptions/route.ts b/app/api/notifications/subscriptions/route.ts
index dc11b9b..fd3e23c 100644
--- a/app/api/notifications/subscriptions/route.ts
+++ b/app/api/notifications/subscriptions/route.ts
@@ -3,8 +3,14 @@ import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store
import { isWebPushConfigured } from "@/lib/push-service";
import { normalizeProvinceName } from "@/lib/provinces";
import {
+ DEFAULT_DISTANCE_UNIT,
+ DEFAULT_PRECIPITATION_UNIT,
+ DEFAULT_PRESSURE_UNIT,
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
+ isDistanceUnit,
+ isPrecipitationUnit,
+ isPressureUnit,
isTemperatureUnit,
isWindSpeedUnit,
} from "@/lib/weather-utils";
@@ -45,6 +51,9 @@ export async function POST(request: Request) {
countyTeryt?: unknown;
temperatureUnit?: unknown;
windSpeedUnit?: unknown;
+ precipitationUnit?: unknown;
+ pressureUnit?: unknown;
+ distanceUnit?: unknown;
} | null;
const subscription = body?.subscription;
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL";
@@ -52,6 +61,9 @@ export async function POST(request: Request) {
const language: Language = body?.language === "en" ? "en" : "pl";
const rawTemperatureUnit = body?.temperatureUnit;
const rawWindSpeedUnit = body?.windSpeedUnit;
+ const rawPrecipitationUnit = body?.precipitationUnit;
+ const rawPressureUnit = body?.pressureUnit;
+ const rawDistanceUnit = body?.distanceUnit;
const enabled = body?.enabled !== false && region === "PL";
if (!isBrowserPushSubscription(subscription) || (enabled && !province)) {
@@ -76,6 +88,9 @@ export async function POST(request: Request) {
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit: isPrecipitationUnit(rawPrecipitationUnit) ? rawPrecipitationUnit : DEFAULT_PRECIPITATION_UNIT,
+ pressureUnit: isPressureUnit(rawPressureUnit) ? rawPressureUnit : DEFAULT_PRESSURE_UNIT,
+ distanceUnit: isDistanceUnit(rawDistanceUnit) ? rawDistanceUnit : DEFAULT_DISTANCE_UNIT,
createdAt: now,
updatedAt: now,
});
diff --git a/app/api/notifications/tomorrow-brief/route.ts b/app/api/notifications/tomorrow-brief/route.ts
index b0d6630..c6bdd11 100644
--- a/app/api/notifications/tomorrow-brief/route.ts
+++ b/app/api/notifications/tomorrow-brief/route.ts
@@ -9,7 +9,7 @@ import {
} from "@/lib/push-store";
import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief";
-import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
+import { DEFAULT_PRECIPITATION_UNIT, DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
export const runtime = "nodejs";
@@ -125,6 +125,7 @@ export async function GET(request: Request) {
language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now,
});
if (!brief) {
diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx
index 5bc8e08..690310c 100644
--- a/components/charts/day-forecast-charts.tsx
+++ b/components/charts/day-forecast-charts.tsx
@@ -5,9 +5,15 @@ import { Card } from "@/components/ui/card";
import { ChartTooltip } from "@/components/charts/chart-tooltip";
import { CHART_COLORS } from "@/lib/chart-theme";
import { useI18n } from "@/lib/i18n";
-import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
+import { formatForecastTemperature } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store";
-import { convertTemperature, formatTemperatureValue, getTemperatureUnitLabel } from "@/lib/weather-utils";
+import {
+ convertPrecipitation,
+ convertTemperature,
+ formatTemperatureValue,
+ getPrecipitationUnitLabel,
+ getTemperatureUnitLabel,
+} from "@/lib/weather-utils";
import type { HourlyForecast } from "@/types/forecast";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
@@ -22,6 +28,15 @@ function formatHour(value: string) {
return value.slice(11, 16);
}
+function formatChartPrecipitation(value: number | null, language: "pl" | "en", unit: string) {
+ if (value === null) return "—";
+ const formattedValue = new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
+ minimumFractionDigits: unit === "in" ? 2 : 0,
+ maximumFractionDigits: unit === "mm" ? (value < 1 ? 2 : 1) : 2,
+ }).format(value);
+ return `${formattedValue} ${unit}`;
+}
+
function ChartLegend({ items }: { items: ChartLegendItem[] }) {
return (
@@ -46,14 +61,16 @@ function ChartLegend({ items }: { items: ChartLegendItem[] }) {
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
+ const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const rows = hours.map((hour) => ({
time: formatHour(hour.time),
temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit),
feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, temperatureUnit),
- precipitation: hour.precipitation,
+ precipitation: hour.precipitation === null ? null : convertPrecipitation(hour.precipitation, precipitationUnit),
precipitationProbability: hour.precipitationProbability,
}));
const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit);
+ const precipitationUnitLabel = getPrecipitationUnitLabel(precipitationUnit);
const temperatureLegendItems: ChartLegendItem[] = [
{ color: CHART_COLORS.temperature, label: t("forecast.temperature"), marker: "line" },
{ color: CHART_COLORS.feelsLike, label: t("forecast.apparentTemperature"), marker: "dashed-line" },
@@ -153,7 +170,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
- unit=" mm"
+ unit={` ${precipitationUnitLabel}`}
/>
entry.dataKey === "precipitation"
- ? formatForecastRainfall(typeof entry.value === "number" ? entry.value : null, language)
+ ? formatChartPrecipitation(
+ typeof entry.value === "number" ? entry.value : null,
+ language,
+ precipitationUnitLabel,
+ )
: `${typeof entry.value === "number" ? entry.value : "—"}%`
}
/>
diff --git a/components/charts/snapshot-chart.tsx b/components/charts/snapshot-chart.tsx
index d492623..fe15074 100644
--- a/components/charts/snapshot-chart.tsx
+++ b/components/charts/snapshot-chart.tsx
@@ -6,7 +6,13 @@ import { ChartTooltip } from "@/components/charts/chart-tooltip";
import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
-import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
+import {
+ convertPrecipitation,
+ convertWindSpeed,
+ convertWindSpeedToBeaufort,
+ getPrecipitationUnitLabel,
+ getWindSpeedUnitLabel,
+} from "@/lib/weather-utils";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"];
@@ -14,10 +20,17 @@ const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels
export function SnapshotChart({ station }: { station: SynopStation }) {
const { t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
+ const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const windDigits = windSpeedUnit === "ms" ? 1 : 0;
const windSpeed =
- station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
- const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45;
+ station.windSpeed === null
+ ? null
+ : windSpeedUnit === "bft"
+ ? convertWindSpeedToBeaufort(station.windSpeed)
+ : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
+ const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : windSpeedUnit === "bft" ? 12 : 45;
+ const rainfall =
+ station.rainfall === null ? null : Number(convertPrecipitation(station.rainfall, precipitationUnit).toFixed(2));
const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
{
@@ -27,7 +40,13 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
max: windMax,
color: SNAPSHOT_COLORS[1],
},
- { name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
+ {
+ name: t("weather.rainfall"),
+ value: rainfall,
+ unit: getPrecipitationUnitLabel(precipitationUnit),
+ max: convertPrecipitation(30, precipitationUnit),
+ color: SNAPSHOT_COLORS[2],
+ },
]
.filter((row) => row.value !== null)
.map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
diff --git a/components/dashboard/weather-brief-card.tsx b/components/dashboard/weather-brief-card.tsx
index d08f478..099aea3 100644
--- a/components/dashboard/weather-brief-card.tsx
+++ b/components/dashboard/weather-brief-card.tsx
@@ -33,6 +33,7 @@ export function WeatherBriefCard({
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
+ const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const isGlobalLocation = region === "GLOBAL";
const province = isGlobalLocation
? null
@@ -50,6 +51,7 @@ export function WeatherBriefCard({
language,
temperatureUnit,
windSpeedUnit,
+ precipitationUnit,
});
}, [
forecast,
@@ -61,6 +63,7 @@ export function WeatherBriefCard({
selectedLocation?.countyTeryt,
temperatureUnit,
windSpeedUnit,
+ precipitationUnit,
]);
const tomorrowBrief = useMemo(() => {
if (!forecast) return null;
@@ -73,6 +76,7 @@ export function WeatherBriefCard({
language,
temperatureUnit,
windSpeedUnit,
+ precipitationUnit,
});
}, [
forecast,
@@ -84,6 +88,7 @@ export function WeatherBriefCard({
selectedLocation?.countyTeryt,
temperatureUnit,
windSpeedUnit,
+ precipitationUnit,
]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
diff --git a/components/forecast/day-forecast-modal.tsx b/components/forecast/day-forecast-modal.tsx
index 7ea3777..9a4cc0e 100644
--- a/components/forecast/day-forecast-modal.tsx
+++ b/components/forecast/day-forecast-modal.tsx
@@ -58,6 +58,7 @@ export function DayForecastModal({
const { language, locale, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
+ const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const closeButtonRef = useRef(null);
const portalRoot = typeof document === "undefined" ? null : document.body;
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
@@ -150,7 +151,7 @@ export function DayForecastModal({
state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
+ const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
@@ -102,7 +103,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
state.temperatureUnit);
+ const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const label = formatDay(day.date, locale, t("forecast.today"), index);
return (
- {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}
+ {getForecastCondition(day.weatherCode, language)} ·{" "}
+ {formatForecastRainfall(day.precipitation, language, precipitationUnit)}
(null);
const displayedLocationName = locationName ?? station.name;
const hasGlobalModel = currentWeather?.coverage === "global-model";
@@ -61,6 +65,7 @@ export function WeatherHero({
distanceKm !== undefined &&
distanceKm !== null &&
distanceKm >= 30;
+ const formattedDistance = formatDistance(distanceKm ?? null, language, distanceUnit);
const displayedStation = currentWeather
? {
...station,
@@ -91,9 +96,13 @@ export function WeatherHero({
: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined
? t("weather.rainfall10m")
: t("weather.rainfallTotal"),
- value: formatRainfall(displayedStation.rainfall, language),
+ value: formatRainfall(displayedStation.rainfall, language, precipitationUnit),
+ },
+ {
+ icon: Gauge,
+ label: t("weather.pressure"),
+ value: formatPressure(displayedStation.pressure, language, pressureUnit),
},
- { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
];
const effectPrecipitation10m =
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
@@ -149,16 +158,16 @@ export function WeatherHero({
: hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
- ? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
+ ? t("location.heroHybridPartial", { station: station.name, distance: formattedDistance })
: locationName
? t("location.heroStationFallbackWithDistance", {
station: station.name,
- distance: distanceKm ?? 0,
+ distance: formattedDistance,
})
: t("location.heroStationFallback", { station: station.name })}
{hasFullHybridAnalysis && locationName && (
- {t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}
+ {t("location.heroNearestStation", { station: station.name, distance: formattedDistance })}
)}
{hasDistantFallback && (
diff --git a/docs/notifications.md b/docs/notifications.md
index 4d136e1..2f7a355 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -52,8 +52,7 @@ Subskrypcja Web Push zapisuje:
- czy aktywny jest brief na jutro,
- współrzędne i nazwę lokalizacji,
- opcjonalny powiat TERYT,
-- wybraną jednostkę temperatury dla briefów wysyłanych z serwera,
-- wybraną jednostkę wiatru dla briefów wysyłanych z serwera.
+- wybrane jednostki prezentacyjne dla briefów wysyłanych z serwera: temperaturę, wiatr, opad, ciśnienie i dystans.
Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.
diff --git a/docs/weather-logic.md b/docs/weather-logic.md
index 9f29b4d..b927870 100644
--- a/docs/weather-logic.md
+++ b/docs/weather-logic.md
@@ -120,7 +120,7 @@ Pole `Cloud` z IMGW Hybrid jest używane jako opis nieba:
| `>= 25` | Częściowe zachmurzenie |
| `< 25` | brak osobnego opisu zachmurzenia |
-Jednostki temperatury i wiatru wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`.
+Jednostki temperatury, wiatru, opadu, ciśnienia i dystansu wybierane w `/settings` dotyczą prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C`, `m/s`, `mm`, `hPa` i `km`.
## Mood i Efekty Wizualne
diff --git a/lib/forecast-utils.ts b/lib/forecast-utils.ts
index 471e6c0..f69bbeb 100644
--- a/lib/forecast-utils.ts
+++ b/lib/forecast-utils.ts
@@ -3,11 +3,13 @@ import { translate } from "@/lib/i18n";
import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
+ DEFAULT_PRECIPITATION_UNIT,
+ formatPrecipitation,
formatTemperature,
formatWindSpeed,
} from "@/lib/weather-utils";
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
-import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
+import type { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
export function getForecastConditionKey(code: number | null): TranslationKey {
if (code === 0) return "forecast.condition.clear";
@@ -47,9 +49,13 @@ export function formatForecastTemperature(
return formatTemperature(value, language, unit);
}
-export function formatForecastRainfall(value: number | null, language: Language) {
+export function formatForecastRainfall(
+ value: number | null,
+ language: Language,
+ unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
+) {
if (value === null) return "—";
- return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
+ return formatPrecipitation(value, language, unit);
}
export function formatForecastWind(
diff --git a/lib/i18n.tsx b/lib/i18n.tsx
index fa8e9c6..7049538 100644
--- a/lib/i18n.tsx
+++ b/lib/i18n.tsx
@@ -54,6 +54,27 @@ const translations = {
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
+ "settings.units.wind.kt": "kt",
+ "settings.units.wind.bft": "Bft",
+ "settings.units.precipitation.title": "Jednostka opadu",
+ "settings.units.precipitation.description":
+ "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
+ "settings.units.precipitation.label": "Wybierz jednostkę opadu",
+ "settings.units.precipitation.mm": "mm",
+ "settings.units.precipitation.cm": "cm",
+ "settings.units.precipitation.in": "in",
+ "settings.units.pressure.title": "Jednostka ciśnienia",
+ "settings.units.pressure.description": "Stosowana w bieżących pomiarach na tym urządzeniu.",
+ "settings.units.pressure.label": "Wybierz jednostkę ciśnienia",
+ "settings.units.pressure.hpa": "hPa",
+ "settings.units.pressure.kpa": "kPa",
+ "settings.units.pressure.inhg": "inHg",
+ "settings.units.pressure.mmhg": "mm Hg",
+ "settings.units.distance.title": "Jednostka dystansu",
+ "settings.units.distance.description": "Stosowana przy odległości do najbliższej stacji.",
+ "settings.units.distance.label": "Wybierz jednostkę dystansu",
+ "settings.units.distance.km": "km",
+ "settings.units.distance.mi": "mi",
"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.",
@@ -121,17 +142,17 @@ const translations = {
"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}: współrzędne miejscowości są używane dla lokalnej analizy IMGW Hybrid. Najbliższa stacja pomiarowa IMGW: {station} · około {distance}.",
"location.currentSourceGlobal":
"{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.",
"location.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo dla lokalizacji: {location}",
"location.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial":
- "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance} km",
- "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance} km",
+ "Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji IMGW: {station} · około {distance}",
+ "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance}",
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}",
- "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance} km",
+ "location.heroStationFallbackWithDistance": "Dane zastępcze ze stacji IMGW: {station} · około {distance}",
"location.heroDistantFallback": "Stacja jest oddalona od lokalizacji. Lokalne warunki mogą się różnić.",
"location.attribution": "Wyszukiwanie miejscowości:",
"location.gpsUse": "Użyj mojej lokalizacji",
@@ -344,6 +365,27 @@ const translations = {
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
+ "settings.units.wind.kt": "kt",
+ "settings.units.wind.bft": "Bft",
+ "settings.units.precipitation.title": "Precipitation unit",
+ "settings.units.precipitation.description":
+ "Used in forecasts, briefs, notifications and current readings on this device.",
+ "settings.units.precipitation.label": "Choose precipitation unit",
+ "settings.units.precipitation.mm": "mm",
+ "settings.units.precipitation.cm": "cm",
+ "settings.units.precipitation.in": "in",
+ "settings.units.pressure.title": "Pressure unit",
+ "settings.units.pressure.description": "Used in current readings on this device.",
+ "settings.units.pressure.label": "Choose pressure unit",
+ "settings.units.pressure.hpa": "hPa",
+ "settings.units.pressure.kpa": "kPa",
+ "settings.units.pressure.inhg": "inHg",
+ "settings.units.pressure.mmhg": "mm Hg",
+ "settings.units.distance.title": "Distance unit",
+ "settings.units.distance.description": "Used for the distance to the nearest station.",
+ "settings.units.distance.label": "Choose distance unit",
+ "settings.units.distance.km": "km",
+ "settings.units.distance.mi": "mi",
"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.",
@@ -410,7 +452,7 @@ const translations = {
"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}: the place coordinates are used for local IMGW Hybrid analysis. Nearest IMGW measurement station: {station} · approximately {distance} away.",
"location.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.currentSourceGlobal":
"{location}: coordinates are used for Open-Meteo model current conditions and forecast.",
@@ -418,11 +460,11 @@ const translations = {
"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",
+ "Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW station: {station} · approximately {distance} away",
+ "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} away",
"location.heroStationFallback": "Fallback data from IMGW station: {station}",
"location.heroStationFallbackWithDistance":
- "Fallback data from IMGW station: {station} · approximately {distance} km away",
+ "Fallback data from IMGW station: {station} · approximately {distance} away",
"location.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
"location.attribution": "Place search:",
"location.gpsUse": "Use my location",
diff --git a/lib/notification-api.ts b/lib/notification-api.ts
index 6d123de..9e189fd 100644
--- a/lib/notification-api.ts
+++ b/lib/notification-api.ts
@@ -1,6 +1,6 @@
import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province";
-import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
+import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
import type { WeatherRegion } from "@/types/weather-region";
interface VapidKeyResponse {
@@ -26,6 +26,9 @@ interface SavePushSubscriptionOptions {
countyTeryt?: string | null;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
+ precipitationUnit?: PrecipitationUnit;
+ pressureUnit?: PressureUnit;
+ distanceUnit?: DistanceUnit;
}
export async function savePushSubscription(
@@ -52,6 +55,9 @@ export async function savePushSubscription(
countyTeryt: options.countyTeryt ?? null,
temperatureUnit: options.temperatureUnit,
windSpeedUnit: options.windSpeedUnit,
+ precipitationUnit: options.precipitationUnit,
+ pressureUnit: options.pressureUnit,
+ distanceUnit: options.distanceUnit,
}),
});
if (!response.ok) throw new Error("Unable to save push subscription.");
diff --git a/lib/push-store.ts b/lib/push-store.ts
index 1360edb..9e80d1e 100644
--- a/lib/push-store.ts
+++ b/lib/push-store.ts
@@ -2,8 +2,14 @@ import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import {
+ DEFAULT_DISTANCE_UNIT,
+ DEFAULT_PRECIPITATION_UNIT,
+ DEFAULT_PRESSURE_UNIT,
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
+ isDistanceUnit,
+ isPrecipitationUnit,
+ isPressureUnit,
isTemperatureUnit,
isWindSpeedUnit,
} from "@/lib/weather-utils";
@@ -31,6 +37,9 @@ interface PushSubscriptionRow {
county_teryt: string | null;
temperature_unit: string | null;
wind_speed_unit: string | null;
+ precipitation_unit: string | null;
+ pressure_unit: string | null;
+ distance_unit: string | null;
created_at: string;
updated_at: string;
}
@@ -71,6 +80,9 @@ function initializeDatabase(database: Database.Database) {
county_teryt TEXT,
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}',
+ precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}',
+ pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}',
+ distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -106,6 +118,27 @@ function initializeDatabase(database: Database.Database) {
if (!columns.some((column) => column.name === "timezone")) {
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run();
}
+ if (!columns.some((column) => column.name === "precipitation_unit")) {
+ database
+ .prepare(
+ `ALTER TABLE push_subscriptions ADD COLUMN precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}'`,
+ )
+ .run();
+ }
+ if (!columns.some((column) => column.name === "pressure_unit")) {
+ database
+ .prepare(
+ `ALTER TABLE push_subscriptions ADD COLUMN pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}'`,
+ )
+ .run();
+ }
+ if (!columns.some((column) => column.name === "distance_unit")) {
+ database
+ .prepare(
+ `ALTER TABLE push_subscriptions ADD COLUMN distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}'`,
+ )
+ .run();
+ }
}
function getDatabase() {
@@ -139,6 +172,11 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
countyTeryt: row.county_teryt,
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit: isPrecipitationUnit(row.precipitation_unit)
+ ? row.precipitation_unit
+ : DEFAULT_PRECIPITATION_UNIT,
+ pressureUnit: isPressureUnit(row.pressure_unit) ? row.pressure_unit : DEFAULT_PRESSURE_UNIT,
+ distanceUnit: isDistanceUnit(row.distance_unit) ? row.distance_unit : DEFAULT_DISTANCE_UNIT,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -167,6 +205,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
county_teryt,
temperature_unit,
wind_speed_unit,
+ precipitation_unit,
+ pressure_unit,
+ distance_unit,
created_at,
updated_at
) VALUES (
@@ -185,6 +226,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
@countyTeryt,
@temperatureUnit,
@windSpeedUnit,
+ @precipitationUnit,
+ @pressureUnit,
+ @distanceUnit,
@createdAt,
@updatedAt
)
@@ -203,6 +247,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
county_teryt = excluded.county_teryt,
temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_unit,
+ precipitation_unit = excluded.precipitation_unit,
+ pressure_unit = excluded.pressure_unit,
+ distance_unit = excluded.distance_unit,
updated_at = excluded.updated_at
`,
)
@@ -222,6 +269,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
countyTeryt: subscription.countyTeryt,
temperatureUnit: subscription.temperatureUnit,
windSpeedUnit: subscription.windSpeedUnit,
+ precipitationUnit: subscription.precipitationUnit,
+ pressureUnit: subscription.pressureUnit,
+ distanceUnit: subscription.distanceUnit,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
});
diff --git a/lib/store.ts b/lib/store.ts
index 18a2dec..8f3394e 100644
--- a/lib/store.ts
+++ b/lib/store.ts
@@ -4,7 +4,7 @@ import { create } from "zustand";
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 type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
import {
DEFAULT_APP_SECTION_VISIBILITY,
normalizeAppSectionVisibility,
@@ -17,7 +17,18 @@ import {
type DashboardSectionId,
type DashboardSectionVisibility,
} from "@/lib/dashboard-sections";
-import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
+import {
+ DEFAULT_DISTANCE_UNIT,
+ DEFAULT_PRECIPITATION_UNIT,
+ DEFAULT_PRESSURE_UNIT,
+ DEFAULT_TEMPERATURE_UNIT,
+ DEFAULT_WIND_SPEED_UNIT,
+ isDistanceUnit,
+ isPrecipitationUnit,
+ isPressureUnit,
+ isTemperatureUnit,
+ isWindSpeedUnit,
+} from "@/lib/weather-utils";
import type { WeatherRegion } from "@/types/weather-region";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
@@ -34,6 +45,9 @@ interface WeatherStore {
warningNotificationProvince: Province | null;
temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit;
+ precipitationUnit: PrecipitationUnit;
+ pressureUnit: PressureUnit;
+ distanceUnit: DistanceUnit;
dashboardSections: DashboardSectionVisibility;
appSections: AppSectionVisibility;
toggleFavorite: (id: string) => void;
@@ -46,6 +60,9 @@ interface WeatherStore {
setWarningNotificationProvince: (province: Province | null) => void;
setTemperatureUnit: (unit: TemperatureUnit) => void;
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
+ setPrecipitationUnit: (unit: PrecipitationUnit) => void;
+ setPressureUnit: (unit: PressureUnit) => void;
+ setDistanceUnit: (unit: DistanceUnit) => void;
setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void;
setAppSectionVisible: (section: AppSectionId, visible: boolean) => void;
}
@@ -63,6 +80,9 @@ export const useWeatherStore = create()(
warningNotificationProvince: null,
temperatureUnit: DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit: DEFAULT_PRECIPITATION_UNIT,
+ pressureUnit: DEFAULT_PRESSURE_UNIT,
+ distanceUnit: DEFAULT_DISTANCE_UNIT,
dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY,
appSections: DEFAULT_APP_SECTION_VISIBILITY,
toggleFavorite: (id) =>
@@ -80,6 +100,9 @@ export const useWeatherStore = create()(
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
+ setPrecipitationUnit: (unit) => set({ precipitationUnit: unit }),
+ setPressureUnit: (unit) => set({ pressureUnit: unit }),
+ setDistanceUnit: (unit) => set({ distanceUnit: unit }),
setDashboardSectionVisible: (section, visible) =>
set((state) => ({
dashboardSections: { ...state.dashboardSections, [section]: visible },
@@ -100,11 +123,21 @@ export const useWeatherStore = create()(
if (!state) return persisted;
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
const appSections = normalizeAppSectionVisibility(state.appSections);
- if (!location) return { ...state, dashboardSections, appSections };
+ const unitPreferences = {
+ temperatureUnit: isTemperatureUnit(state.temperatureUnit) ? state.temperatureUnit : DEFAULT_TEMPERATURE_UNIT,
+ windSpeedUnit: isWindSpeedUnit(state.windSpeedUnit) ? state.windSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit: isPrecipitationUnit(state.precipitationUnit)
+ ? state.precipitationUnit
+ : DEFAULT_PRECIPITATION_UNIT,
+ pressureUnit: isPressureUnit(state.pressureUnit) ? state.pressureUnit : DEFAULT_PRESSURE_UNIT,
+ distanceUnit: isDistanceUnit(state.distanceUnit) ? state.distanceUnit : DEFAULT_DISTANCE_UNIT,
+ };
+ if (!location) return { ...state, ...unitPreferences, dashboardSections, appSections };
const countryCode = location.countryCode ?? (location.province ? "PL" : null);
const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
return {
...state,
+ ...unitPreferences,
dashboardSections,
appSections,
selectedLocation: {
diff --git a/lib/weather-brief.ts b/lib/weather-brief.ts
index ad6e368..b20bde7 100644
--- a/lib/weather-brief.ts
+++ b/lib/weather-brief.ts
@@ -8,10 +8,12 @@ import { warningMatchesCounty } from "@/lib/warning-regions";
import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
+ DEFAULT_PRECIPITATION_UNIT,
+ formatPrecipitation as formatDisplayPrecipitation,
formatTemperature as formatDisplayTemperature,
formatWindSpeed,
} from "@/lib/weather-utils";
-import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
+import type { PrecipitationUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -33,19 +35,10 @@ interface BuildWeatherBriefInput {
language: Language;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
+ precipitationUnit?: PrecipitationUnit;
now?: Date;
}
-function formatNumber(value: number, language: Language, digits = 0) {
- return new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
- maximumFractionDigits: digits,
- }).format(value);
-}
-
-function formatRainfall(value: number, language: Language) {
- return `${formatNumber(value, language, 1)} mm`;
-}
-
function formatHour(value: string) {
return value.slice(11, 16);
}
@@ -237,6 +230,7 @@ export function buildWeatherBrief({
language,
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit = DEFAULT_PRECIPITATION_UNIT,
now = new Date(),
}: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24);
@@ -313,8 +307,8 @@ export function buildWeatherBrief({
: "";
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}`,
+ ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
+ : `Rainfall: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
);
}
if (maximumWind !== null) {
@@ -371,6 +365,7 @@ export function buildTomorrowWeatherBrief({
language,
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
+ precipitationUnit = DEFAULT_PRECIPITATION_UNIT,
now = new Date(),
}: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1);
@@ -453,8 +448,8 @@ export function buildTomorrowWeatherBrief({
: "";
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}`,
+ ? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
+ : `Precipitation: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
);
} else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
@@ -469,7 +464,7 @@ export function buildTomorrowWeatherBrief({
const rainPushPart =
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
- ? formatRainfall(rainfallTotal, language)
+ ? formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)
: null;
const pushParts = [
`${locationName}:`,
diff --git a/lib/weather-utils.ts b/lib/weather-utils.ts
index f03c985..06b36a4 100644
--- a/lib/weather-utils.ts
+++ b/lib/weather-utils.ts
@@ -11,13 +11,19 @@ import type {
import { translate, type Language } from "@/lib/i18n";
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
-import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
+import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
const locales: Record = { pl: "pl-PL", en: "en-GB" };
export const DEFAULT_TEMPERATURE_UNIT: TemperatureUnit = "c";
export const TEMPERATURE_UNITS: TemperatureUnit[] = ["c", "f"];
export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "kmh";
-export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
+export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph", "kt", "bft"];
+export const DEFAULT_PRECIPITATION_UNIT: PrecipitationUnit = "mm";
+export const PRECIPITATION_UNITS: PrecipitationUnit[] = ["mm", "cm", "in"];
+export const DEFAULT_PRESSURE_UNIT: PressureUnit = "hpa";
+export const PRESSURE_UNITS: PressureUnit[] = ["hpa", "kpa", "inhg", "mmhg"];
+export const DEFAULT_DISTANCE_UNIT: DistanceUnit = "km";
+export const DISTANCE_UNITS: DistanceUnit[] = ["km", "mi"];
const temperatureUnitLabels: Record = {
c: "°C",
@@ -28,6 +34,26 @@ const windSpeedUnitLabels: Record = {
ms: "m/s",
kmh: "km/h",
mph: "mph",
+ kt: "kt",
+ bft: "Bft",
+};
+
+const precipitationUnitLabels: Record = {
+ mm: "mm",
+ cm: "cm",
+ in: "in",
+};
+
+const pressureUnitLabels: Record = {
+ hpa: "hPa",
+ kpa: "kPa",
+ inhg: "inHg",
+ mmhg: "mm Hg",
+};
+
+const distanceUnitLabels: Record = {
+ km: "km",
+ mi: "mi",
};
export function toNumber(value: unknown): number | null {
@@ -152,10 +178,6 @@ export function formatTemperature(
: formatTemperatureValue(convertTemperature(value, unit), language, unit);
}
-export function formatPressure(value: number | null, language: Language = "pl") {
- return value === null ? translate(language, "common.noData") : `${value.toFixed(1)} hPa`;
-}
-
export function formatHumidity(value: number | null, language: Language = "pl") {
return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`;
}
@@ -168,18 +190,60 @@ export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
return windSpeedUnitLabels[unit];
}
+export function isPrecipitationUnit(value: unknown): value is PrecipitationUnit {
+ return typeof value === "string" && PRECIPITATION_UNITS.includes(value as PrecipitationUnit);
+}
+
+export function getPrecipitationUnitLabel(unit: PrecipitationUnit) {
+ return precipitationUnitLabels[unit];
+}
+
+export function isPressureUnit(value: unknown): value is PressureUnit {
+ return typeof value === "string" && PRESSURE_UNITS.includes(value as PressureUnit);
+}
+
+export function getPressureUnitLabel(unit: PressureUnit) {
+ return pressureUnitLabels[unit];
+}
+
+export function isDistanceUnit(value: unknown): value is DistanceUnit {
+ return typeof value === "string" && DISTANCE_UNITS.includes(value as DistanceUnit);
+}
+
+export function getDistanceUnitLabel(unit: DistanceUnit) {
+ return distanceUnitLabels[unit];
+}
+
export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
if (unit === "kmh") return speed * 3.6;
if (unit === "mph") return speed * 2.2369362921;
+ if (unit === "kt") return speed * 1.9438444924;
return speed;
}
+export function convertWindSpeedToBeaufort(speed: number) {
+ if (speed < 0.3) return 0;
+ if (speed < 1.6) return 1;
+ if (speed < 3.4) return 2;
+ if (speed < 5.5) return 3;
+ if (speed < 8) return 4;
+ if (speed < 10.8) return 5;
+ if (speed < 13.9) return 6;
+ if (speed < 17.2) return 7;
+ if (speed < 20.8) return 8;
+ if (speed < 24.5) return 9;
+ if (speed < 28.5) return 10;
+ if (speed < 32.7) return 11;
+ return 12;
+}
+
export function formatWindSpeed(
speed: number | null,
language: Language = "pl",
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
) {
if (speed === null) return translate(language, "common.noData");
+ if (unit === "bft") return `${convertWindSpeedToBeaufort(speed)} ${getWindSpeedUnitLabel(unit)}`;
const convertedSpeed = convertWindSpeed(speed, unit);
const digits = unit === "ms" ? 1 : 0;
const formattedSpeed = new Intl.NumberFormat(locales[language], {
@@ -200,8 +264,73 @@ export function formatWind(
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
}
-export function formatRainfall(value: number | null, language: Language = "pl") {
- return value === null ? translate(language, "common.noData") : `${value.toFixed(value < 1 ? 2 : 1)} mm`;
+export function formatRainfall(
+ value: number | null,
+ language: Language = "pl",
+ unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
+) {
+ return formatPrecipitation(value, language, unit);
+}
+
+export function convertPrecipitation(value: number, unit: PrecipitationUnit) {
+ if (unit === "cm") return value / 10;
+ if (unit === "in") return value / 25.4;
+ return value;
+}
+
+export function formatPrecipitation(
+ value: number | null,
+ language: Language = "pl",
+ unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
+) {
+ if (value === null) return translate(language, "common.noData");
+ const convertedValue = convertPrecipitation(value, unit);
+ const digits = unit === "mm" ? (convertedValue < 1 ? 2 : 1) : unit === "cm" ? 2 : 2;
+ const formattedValue = new Intl.NumberFormat(locales[language], {
+ minimumFractionDigits: unit === "in" ? 2 : 0,
+ maximumFractionDigits: digits,
+ }).format(convertedValue);
+ return `${formattedValue} ${getPrecipitationUnitLabel(unit)}`;
+}
+
+export function convertPressure(value: number, unit: PressureUnit) {
+ if (unit === "kpa") return value / 10;
+ if (unit === "inhg") return value * 0.0295299830714;
+ if (unit === "mmhg") return value * 0.750061683;
+ return value;
+}
+
+export function formatPressure(
+ value: number | null,
+ language: Language = "pl",
+ unit: PressureUnit = DEFAULT_PRESSURE_UNIT,
+) {
+ if (value === null) return translate(language, "common.noData");
+ const convertedValue = convertPressure(value, unit);
+ const digits = unit === "inhg" ? 2 : unit === "kpa" ? 1 : unit === "mmhg" ? 0 : 1;
+ const formattedValue = new Intl.NumberFormat(locales[language], {
+ minimumFractionDigits: unit === "inhg" ? 2 : 0,
+ maximumFractionDigits: digits,
+ }).format(convertedValue);
+ return `${formattedValue} ${getPressureUnitLabel(unit)}`;
+}
+
+export function convertDistance(value: number, unit: DistanceUnit) {
+ if (unit === "mi") return value * 0.621371;
+ return value;
+}
+
+export function formatDistance(
+ value: number | null,
+ language: Language = "pl",
+ unit: DistanceUnit = DEFAULT_DISTANCE_UNIT,
+) {
+ if (value === null) return translate(language, "common.noData");
+ const convertedValue = convertDistance(value, unit);
+ const formattedValue = new Intl.NumberFormat(locales[language], {
+ maximumFractionDigits: convertedValue < 10 ? 1 : 0,
+ }).format(convertedValue);
+ return `${formattedValue} ${getDistanceUnitLabel(unit)}`;
}
export function formatWaterLevel(value: number | null, language: Language = "pl") {
diff --git a/tests/weather-brief.test.ts b/tests/weather-brief.test.ts
index 185939b..30e70dd 100644
--- a/tests/weather-brief.test.ts
+++ b/tests/weather-brief.test.ts
@@ -54,10 +54,16 @@ function warning(overrides: Partial = {}): WeatherWarning {
}
describe("weather briefs", () => {
- it("uses selected temperature and wind units in the morning brief", () => {
+ it("uses selected temperature, wind and precipitation units in the morning brief", () => {
const brief = buildWeatherBrief({
forecast: forecast([
- hour({ time: "2026-06-14T08:00", temperature: 10, windSpeed: 4, precipitationProbability: 80 }),
+ hour({
+ time: "2026-06-14T08:00",
+ temperature: 10,
+ windSpeed: 4,
+ precipitationProbability: 80,
+ precipitation: 25.4,
+ }),
hour({ time: "2026-06-14T09:00", temperature: 12, windSpeed: 5, precipitationProbability: 30 }),
]),
warnings: [],
@@ -67,6 +73,7 @@ describe("weather briefs", () => {
language: "en",
temperatureUnit: "f",
windSpeedUnit: "mph",
+ precipitationUnit: "in",
now: new Date("2026-06-14T06:00:00.000Z"),
});
@@ -74,25 +81,34 @@ describe("weather briefs", () => {
expect(brief?.pushBody).toContain("50°F-54°F");
expect(brief?.pushBody).toContain("wind 11 mph");
expect(brief?.pushBody).not.toContain("km/h");
+ expect(brief?.body.join(" ")).toContain("1.00 in");
});
it("warns about tomorrow storms from model weather codes without IMGW warning", () => {
const brief = buildTomorrowWeatherBrief({
forecast: forecast([
hour({ time: "2026-06-15T08:00", temperature: 14, weatherCode: 3, precipitationProbability: 20 }),
- hour({ time: "2026-06-15T15:00", temperature: 21, weatherCode: 95, precipitationProbability: 30 }),
+ hour({
+ time: "2026-06-15T15:00",
+ temperature: 21,
+ weatherCode: 95,
+ precipitationProbability: 30,
+ precipitation: 25.4,
+ }),
]),
warnings: [],
province: "mazowieckie",
countyTeryt: "1465",
locationName: "Warsaw",
language: "en",
+ precipitationUnit: "in",
now: new Date("2026-06-14T16:00:00.000Z"),
});
expect(brief?.severity).toBe("warning");
expect(brief?.headline).toBe("Thunderstorms are possible tomorrow");
expect(brief?.pushBody).toContain("thunderstorms");
+ expect(brief?.pushBody).toContain("1.00 in");
expect(brief?.pushBody).not.toContain("IMGW");
});
diff --git a/tests/weather-utils.test.ts b/tests/weather-utils.test.ts
index 1ef442f..22f8beb 100644
--- a/tests/weather-utils.test.ts
+++ b/tests/weather-utils.test.ts
@@ -1,9 +1,18 @@
import { describe, expect, it } from "vitest";
import {
+ DEFAULT_DISTANCE_UNIT,
+ DEFAULT_PRECIPITATION_UNIT,
+ DEFAULT_PRESSURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
+ formatDistance,
+ formatPrecipitation,
+ formatPressure,
formatTemperature,
formatWindSpeed,
+ isDistanceUnit,
+ isPrecipitationUnit,
+ isPressureUnit,
isTemperatureUnit,
isWindSpeedUnit,
} from "@/lib/weather-utils";
@@ -18,12 +27,40 @@ describe("weather utilities", () => {
expect(formatTemperature(10, "en", "f")).toBe("50°F");
expect(formatWindSpeed(4, "en", "mph")).toBe("9 mph");
expect(formatWindSpeed(4, "en", "ms")).toBe("4.0 m/s");
+ expect(formatWindSpeed(4, "en", "kt")).toBe("8 kt");
+ expect(formatWindSpeed(4, "en", "bft")).toBe("3 Bft");
+ });
+
+ it("formats selected precipitation, pressure and distance units", () => {
+ expect(DEFAULT_PRECIPITATION_UNIT).toBe("mm");
+ expect(DEFAULT_PRESSURE_UNIT).toBe("hpa");
+ expect(DEFAULT_DISTANCE_UNIT).toBe("km");
+
+ expect(formatPrecipitation(25.4, "en", "mm")).toBe("25.4 mm");
+ expect(formatPrecipitation(10, "en", "cm")).toBe("1 cm");
+ expect(formatPrecipitation(25.4, "en", "in")).toBe("1.00 in");
+
+ expect(formatPressure(1013.25, "en", "hpa")).toBe("1,013.3 hPa");
+ expect(formatPressure(1013.25, "en", "kpa")).toBe("101.3 kPa");
+ expect(formatPressure(1013.25, "en", "inhg")).toBe("29.92 inHg");
+ expect(formatPressure(1013.25, "en", "mmhg")).toBe("760 mm Hg");
+
+ expect(formatDistance(10, "en", "km")).toBe("10 km");
+ expect(formatDistance(10, "en", "mi")).toBe("6.2 mi");
});
it("validates supported presentation units", () => {
expect(isTemperatureUnit("c")).toBe(true);
expect(isTemperatureUnit("kelvin")).toBe(false);
expect(isWindSpeedUnit("kmh")).toBe(true);
+ expect(isWindSpeedUnit("kt")).toBe(true);
+ expect(isWindSpeedUnit("bft")).toBe(true);
expect(isWindSpeedUnit("knots")).toBe(false);
+ expect(isPrecipitationUnit("in")).toBe(true);
+ expect(isPrecipitationUnit("l")).toBe(false);
+ expect(isPressureUnit("kpa")).toBe(true);
+ expect(isPressureUnit("mbar")).toBe(false);
+ expect(isDistanceUnit("mi")).toBe(true);
+ expect(isDistanceUnit("m")).toBe(false);
});
});
diff --git a/types/notifications.ts b/types/notifications.ts
index abdead5..6765999 100644
--- a/types/notifications.ts
+++ b/types/notifications.ts
@@ -1,6 +1,6 @@
import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province";
-import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
+import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
import type { WeatherRegion } from "@/types/weather-region";
export interface PushSubscriptionKeys {
@@ -30,6 +30,9 @@ export interface WarningPushSubscription {
countyTeryt: string | null;
temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit;
+ precipitationUnit: PrecipitationUnit;
+ pressureUnit: PressureUnit;
+ distanceUnit: DistanceUnit;
createdAt: string;
updatedAt: string;
}
diff --git a/types/units.ts b/types/units.ts
index 0d71116..1c4401e 100644
--- a/types/units.ts
+++ b/types/units.ts
@@ -1,2 +1,5 @@
export type TemperatureUnit = "c" | "f";
-export type WindSpeedUnit = "ms" | "kmh" | "mph";
+export type WindSpeedUnit = "ms" | "kmh" | "mph" | "kt" | "bft";
+export type PrecipitationUnit = "mm" | "cm" | "in";
+export type PressureUnit = "hpa" | "inhg" | "mmhg" | "kpa";
+export type DistanceUnit = "km" | "mi";