feat: add weather unit preferences
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled

This commit is contained in:
zv
2026-07-04 20:16:11 +02:00
parent ab6b7b414f
commit 91acdb39b8
27 changed files with 623 additions and 76 deletions

View File

@@ -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. - 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. - 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. - 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`. - 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. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.

View File

@@ -9,7 +9,7 @@ import {
} from "@/lib/push-store"; } from "@/lib/push-store";
import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service"; import { isWebPushConfigured, sendMorningBriefNotification } from "@/lib/push-service";
import { buildWeatherBrief } from "@/lib/weather-brief"; 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"; export const runtime = "nodejs";
@@ -113,6 +113,7 @@ export async function GET(request: Request) {
language: subscription.language, language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now, now,
}); });
if (!brief) { if (!brief) {

View File

@@ -3,8 +3,14 @@ import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store
import { isWebPushConfigured } from "@/lib/push-service"; import { isWebPushConfigured } from "@/lib/push-service";
import { normalizeProvinceName } from "@/lib/provinces"; import { normalizeProvinceName } from "@/lib/provinces";
import { import {
DEFAULT_DISTANCE_UNIT,
DEFAULT_PRECIPITATION_UNIT,
DEFAULT_PRESSURE_UNIT,
DEFAULT_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT, DEFAULT_WIND_SPEED_UNIT,
isDistanceUnit,
isPrecipitationUnit,
isPressureUnit,
isTemperatureUnit, isTemperatureUnit,
isWindSpeedUnit, isWindSpeedUnit,
} from "@/lib/weather-utils"; } from "@/lib/weather-utils";
@@ -45,6 +51,9 @@ export async function POST(request: Request) {
countyTeryt?: unknown; countyTeryt?: unknown;
temperatureUnit?: unknown; temperatureUnit?: unknown;
windSpeedUnit?: unknown; windSpeedUnit?: unknown;
precipitationUnit?: unknown;
pressureUnit?: unknown;
distanceUnit?: unknown;
} | null; } | null;
const subscription = body?.subscription; const subscription = body?.subscription;
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL"; 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 language: Language = body?.language === "en" ? "en" : "pl";
const rawTemperatureUnit = body?.temperatureUnit; const rawTemperatureUnit = body?.temperatureUnit;
const rawWindSpeedUnit = body?.windSpeedUnit; const rawWindSpeedUnit = body?.windSpeedUnit;
const rawPrecipitationUnit = body?.precipitationUnit;
const rawPressureUnit = body?.pressureUnit;
const rawDistanceUnit = body?.distanceUnit;
const enabled = body?.enabled !== false && region === "PL"; const enabled = body?.enabled !== false && region === "PL";
if (!isBrowserPushSubscription(subscription) || (enabled && !province)) { 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, countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT, temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_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, createdAt: now,
updatedAt: now, updatedAt: now,
}); });

View File

@@ -9,7 +9,7 @@ import {
} from "@/lib/push-store"; } from "@/lib/push-store";
import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service"; import { isWebPushConfigured, sendTomorrowBriefNotification } from "@/lib/push-service";
import { buildTomorrowWeatherBrief } from "@/lib/weather-brief"; 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"; export const runtime = "nodejs";
@@ -125,6 +125,7 @@ export async function GET(request: Request) {
language: subscription.language, language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT, temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT, windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
precipitationUnit: subscription.precipitationUnit ?? DEFAULT_PRECIPITATION_UNIT,
now, now,
}); });
if (!brief) { if (!brief) {

View File

@@ -5,9 +5,15 @@ import { Card } from "@/components/ui/card";
import { ChartTooltip } from "@/components/charts/chart-tooltip"; import { ChartTooltip } from "@/components/charts/chart-tooltip";
import { CHART_COLORS } from "@/lib/chart-theme"; import { CHART_COLORS } from "@/lib/chart-theme";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils"; import { formatForecastTemperature } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store"; 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"; import type { HourlyForecast } from "@/types/forecast";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
@@ -22,6 +28,15 @@ function formatHour(value: string) {
return value.slice(11, 16); 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[] }) { function ChartLegend({ items }: { items: ChartLegendItem[] }) {
return ( return (
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-medium text-muted"> <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-medium text-muted">
@@ -46,14 +61,16 @@ function ChartLegend({ items }: { items: ChartLegendItem[] }) {
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const rows = hours.map((hour) => ({ const rows = hours.map((hour) => ({
time: formatHour(hour.time), time: formatHour(hour.time),
temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit), temperature: hour.temperature === null ? null : convertTemperature(hour.temperature, temperatureUnit),
feelsLike: hour.feelsLike === null ? null : convertTemperature(hour.feelsLike, 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, precipitationProbability: hour.precipitationProbability,
})); }));
const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit); const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit);
const precipitationUnitLabel = getPrecipitationUnitLabel(precipitationUnit);
const temperatureLegendItems: ChartLegendItem[] = [ const temperatureLegendItems: ChartLegendItem[] = [
{ color: CHART_COLORS.temperature, label: t("forecast.temperature"), marker: "line" }, { color: CHART_COLORS.temperature, label: t("forecast.temperature"), marker: "line" },
{ color: CHART_COLORS.feelsLike, label: t("forecast.apparentTemperature"), marker: "dashed-line" }, { color: CHART_COLORS.feelsLike, label: t("forecast.apparentTemperature"), marker: "dashed-line" },
@@ -153,7 +170,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }} tick={{ fill: "currentColor", fontSize: 11 }}
unit=" mm" unit={` ${precipitationUnitLabel}`}
/> />
<YAxis <YAxis
yAxisId="probability" yAxisId="probability"
@@ -170,7 +187,11 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<ChartTooltip <ChartTooltip
valueFormatter={(entry) => valueFormatter={(entry) =>
entry.dataKey === "precipitation" 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 : "—"}%` : `${typeof entry.value === "number" ? entry.value : "—"}%`
} }
/> />

View File

@@ -6,7 +6,13 @@ import { ChartTooltip } from "@/components/charts/chart-tooltip";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store"; 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 INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = ["hsl(var(--chart-temperature))", "hsl(var(--chart-feels-like))", "hsl(var(--chart-rainfall))"]; 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 }) { export function SnapshotChart({ station }: { station: SynopStation }) {
const { t } = useI18n(); const { t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const windDigits = windSpeedUnit === "ms" ? 1 : 0; const windDigits = windSpeedUnit === "ms" ? 1 : 0;
const windSpeed = const windSpeed =
station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits)); station.windSpeed === null
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45; ? 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 = [ const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] }, { 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, max: windMax,
color: SNAPSHOT_COLORS[1], 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) .filter((row) => row.value !== null)
.map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) })); .map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));

View File

@@ -33,6 +33,7 @@ export function WeatherBriefCard({
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const isGlobalLocation = region === "GLOBAL"; const isGlobalLocation = region === "GLOBAL";
const province = isGlobalLocation const province = isGlobalLocation
? null ? null
@@ -50,6 +51,7 @@ export function WeatherBriefCard({
language, language,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
}); });
}, [ }, [
forecast, forecast,
@@ -61,6 +63,7 @@ export function WeatherBriefCard({
selectedLocation?.countyTeryt, selectedLocation?.countyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
]); ]);
const tomorrowBrief = useMemo(() => { const tomorrowBrief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
@@ -73,6 +76,7 @@ export function WeatherBriefCard({
language, language,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
}); });
}, [ }, [
forecast, forecast,
@@ -84,6 +88,7 @@ export function WeatherBriefCard({
selectedLocation?.countyTeryt, selectedLocation?.countyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
]); ]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {

View File

@@ -58,6 +58,7 @@ export function DayForecastModal({
const { language, locale, t } = useI18n(); const { language, locale, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const closeButtonRef = useRef<HTMLButtonElement>(null); const closeButtonRef = useRef<HTMLButtonElement>(null);
const portalRoot = typeof document === "undefined" ? null : document.body; const portalRoot = typeof document === "undefined" ? null : document.body;
const maximumWind = useMemo(() => getMaximumWind(hours), [hours]); const maximumWind = useMemo(() => getMaximumWind(hours), [hours]);
@@ -150,7 +151,7 @@ export function DayForecastModal({
<DayMetric <DayMetric
icon={Droplets} icon={Droplets}
label={t("forecast.precipitation")} label={t("forecast.precipitation")}
value={formatForecastRainfall(day.precipitation, language)} value={formatForecastRainfall(day.precipitation, language, precipitationUnit)}
/> />
<DayMetric <DayMetric
icon={Wind} icon={Wind}

View File

@@ -77,6 +77,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature)); const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature)); const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed)); const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
@@ -102,7 +103,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
<HourlySummaryMetric <HourlySummaryMetric
icon={CloudRain} icon={CloudRain}
label={t("forecast.rainfallTotal")} label={t("forecast.rainfallTotal")}
value={formatForecastRainfall(rainfallTotal, language)} value={formatForecastRainfall(rainfallTotal, language, precipitationUnit)}
/> />
<HourlySummaryMetric <HourlySummaryMetric
icon={Droplets} icon={Droplets}
@@ -125,6 +126,7 @@ function DailyForecastRow({
}) { }) {
const { language, locale, t } = useI18n(); const { language, locale, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const label = formatDay(day.date, locale, t("forecast.today"), index); const label = formatDay(day.date, locale, t("forecast.today"), index);
return ( return (
<motion.li <motion.li
@@ -144,7 +146,8 @@ function DailyForecastRow({
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" /> <ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
<span className="truncate text-xs text-muted"> <span className="truncate text-xs text-muted">
{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} {getForecastCondition(day.weatherCode, language)} ·{" "}
{formatForecastRainfall(day.precipitation, language, precipitationUnit)}
</span> </span>
</div> </div>
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"> <span className="hidden items-center gap-1 text-xs text-accent sm:flex">
@@ -177,6 +180,7 @@ export function ForecastPanel({
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null); const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
const closeDayDetails = useCallback(() => setSelectedDay(null), []); const closeDayDetails = useCallback(() => setSelectedDay(null), []);
@@ -261,7 +265,7 @@ export function ForecastPanel({
</p> </p>
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}> <p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
<CloudRain className="size-3 text-accent" /> <CloudRain className="size-3 text-accent" />
{formatForecastRainfall(hour.precipitation, language)} {formatForecastRainfall(hour.precipitation, language, precipitationUnit)}
</p> </p>
</div> </div>
</motion.li> </motion.li>

View File

@@ -6,10 +6,13 @@ import {
BellRing, BellRing,
ChevronDown, ChevronDown,
Clock3, Clock3,
CloudRain,
Gauge,
Languages, Languages,
LayoutDashboard, LayoutDashboard,
MapPinned, MapPinned,
Palette, Palette,
Ruler,
Settings, Settings,
Smartphone, Smartphone,
Thermometer, Thermometer,
@@ -36,8 +39,14 @@ import {
} from "@/lib/notification-api"; } from "@/lib/notification-api";
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces"; import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils"; import {
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; DISTANCE_UNITS,
PRECIPITATION_UNITS,
PRESSURE_UNITS,
TEMPERATURE_UNITS,
WIND_SPEED_UNITS,
} from "@/lib/weather-utils";
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
type NotificationSupportStatus = type NotificationSupportStatus =
| "checking" | "checking"
@@ -154,11 +163,17 @@ function NotificationSwitchLabel({
const windSpeedUnitKeys: Record< const windSpeedUnitKeys: Record<
WindSpeedUnit, WindSpeedUnit,
"settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph" | "settings.units.wind.ms"
| "settings.units.wind.kmh"
| "settings.units.wind.mph"
| "settings.units.wind.kt"
| "settings.units.wind.bft"
> = { > = {
ms: "settings.units.wind.ms", ms: "settings.units.wind.ms",
kmh: "settings.units.wind.kmh", kmh: "settings.units.wind.kmh",
mph: "settings.units.wind.mph", mph: "settings.units.wind.mph",
kt: "settings.units.wind.kt",
bft: "settings.units.wind.bft",
}; };
const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c" | "settings.units.temperature.f"> = { const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c" | "settings.units.temperature.f"> = {
@@ -166,6 +181,33 @@ const temperatureUnitKeys: Record<TemperatureUnit, "settings.units.temperature.c
f: "settings.units.temperature.f", f: "settings.units.temperature.f",
}; };
const precipitationUnitKeys: Record<
PrecipitationUnit,
"settings.units.precipitation.mm" | "settings.units.precipitation.cm" | "settings.units.precipitation.in"
> = {
mm: "settings.units.precipitation.mm",
cm: "settings.units.precipitation.cm",
in: "settings.units.precipitation.in",
};
const pressureUnitKeys: Record<
PressureUnit,
| "settings.units.pressure.hpa"
| "settings.units.pressure.kpa"
| "settings.units.pressure.inhg"
| "settings.units.pressure.mmhg"
> = {
hpa: "settings.units.pressure.hpa",
kpa: "settings.units.pressure.kpa",
inhg: "settings.units.pressure.inhg",
mmhg: "settings.units.pressure.mmhg",
};
const distanceUnitKeys: Record<DistanceUnit, "settings.units.distance.km" | "settings.units.distance.mi"> = {
km: "settings.units.distance.km",
mi: "settings.units.distance.mi",
};
export function SettingsPage() { export function SettingsPage() {
const { language, t } = useI18n(); const { language, t } = useI18n();
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking"); const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
@@ -185,6 +227,9 @@ export function SettingsPage() {
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
const dashboardSections = useWeatherStore((state) => state.dashboardSections); const dashboardSections = useWeatherStore((state) => state.dashboardSections);
const appSections = useWeatherStore((state) => state.appSections); const appSections = useWeatherStore((state) => state.appSections);
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
@@ -194,6 +239,9 @@ export function SettingsPage() {
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit); const setTemperatureUnit = useWeatherStore((state) => state.setTemperatureUnit);
const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit); const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit);
const setPrecipitationUnit = useWeatherStore((state) => state.setPrecipitationUnit);
const setPressureUnit = useWeatherStore((state) => state.setPressureUnit);
const setDistanceUnit = useWeatherStore((state) => state.setDistanceUnit);
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible); const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible); const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
@@ -268,6 +316,9 @@ export function SettingsPage() {
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}).catch(() => undefined); }).catch(() => undefined);
} }
@@ -289,6 +340,9 @@ export function SettingsPage() {
notificationTimezone, notificationTimezone,
selectedRegion, selectedRegion,
temperatureUnit, temperatureUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
tomorrowBriefEnabled, tomorrowBriefEnabled,
vapidPublicKey, vapidPublicKey,
windSpeedUnit, windSpeedUnit,
@@ -376,6 +430,9 @@ export function SettingsPage() {
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}); });
setNotificationsEnabled(true); setNotificationsEnabled(true);
setNotificationMessage(t("settings.notifications.saveSuccess")); setNotificationMessage(t("settings.notifications.saveSuccess"));
@@ -444,6 +501,9 @@ export function SettingsPage() {
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -470,6 +530,9 @@ export function SettingsPage() {
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -554,6 +617,90 @@ export function SettingsPage() {
))} ))}
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
icon={CloudRain}
title={t("settings.units.precipitation.title")}
description={t("settings.units.precipitation.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.precipitation.label")}
>
{PRECIPITATION_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={precipitationUnit === unit}
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
precipitationUnit === unit
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
}`}
onClick={() => setPrecipitationUnit(unit)}
>
{t(precipitationUnitKeys[unit])}
</button>
))}
</div>
</SettingsRow>
<SettingsRow
icon={Gauge}
title={t("settings.units.pressure.title")}
description={t("settings.units.pressure.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.pressure.label")}
>
{PRESSURE_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={pressureUnit === unit}
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
pressureUnit === unit
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
}`}
onClick={() => setPressureUnit(unit)}
>
{t(pressureUnitKeys[unit])}
</button>
))}
</div>
</SettingsRow>
<SettingsRow
icon={Ruler}
title={t("settings.units.distance.title")}
description={t("settings.units.distance.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.distance.label")}
>
{DISTANCE_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={distanceUnit === unit}
className={`min-w-14 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
distanceUnit === unit
? "border-accent/45 bg-accent/10 text-accent shadow-soft"
: "border-transparent text-muted hover:bg-surface-muted/70 hover:text-foreground"
}`}
onClick={() => setDistanceUnit(unit)}
>
{t(distanceUnitKeys[unit])}
</button>
))}
</div>
</SettingsRow>
</Card> </Card>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">

View File

@@ -19,6 +19,8 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed); const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
const metrics = [ const metrics = [
{ {
@@ -36,7 +38,7 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
{ {
icon: Gauge, icon: Gauge,
label: t("weather.pressure"), label: t("weather.pressure"),
value: formatPressure(station.pressure, language), value: formatPressure(station.pressure, language, pressureUnit),
detail: t("weather.pressureDetail"), detail: t("weather.pressureDetail"),
}, },
{ {
@@ -57,7 +59,7 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
{ {
icon: Umbrella, icon: Umbrella,
label: t("weather.rainfallTotal"), label: t("weather.rainfallTotal"),
value: formatRainfall(station.rainfall, language), value: formatRainfall(station.rainfall, language, precipitationUnit),
detail: t("weather.rainfallDetail"), detail: t("weather.rainfallDetail"),
}, },
{ {

View File

@@ -7,6 +7,7 @@ import { useLocationSearch } from "@/hooks/use-location-search";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils"; import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { formatDistance } from "@/lib/weather-utils";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location"; import type { SelectedLocation } from "@/types/location";
@@ -21,6 +22,7 @@ export function LocationSearch({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
const selectLocation = useWeatherStore((state) => state.selectLocation); const selectLocation = useWeatherStore((state) => state.selectLocation);
const { data: locations, isFetching, isError } = useLocationSearch(query, language); const { data: locations, isFetching, isError } = useLocationSearch(query, language);
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]); const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
@@ -112,7 +114,7 @@ export function LocationSearch({
{t("location.nearest")} {t("location.nearest")}
<br /> <br />
<strong className="font-semibold text-foreground"> <strong className="font-semibold text-foreground">
{selected.stationName} · {selected.distanceKm} km {selected.stationName} · {formatDistance(selected.distanceKm, language, distanceUnit)}
</strong> </strong>
</span> </span>
) : ( ) : (
@@ -135,7 +137,7 @@ export function LocationSearch({
? t("location.currentSource", { ? t("location.currentSource", {
location: selectedLocation.name, location: selectedLocation.name,
station: selectedLocation.stationName, station: selectedLocation.stationName,
distance: selectedLocation.distanceKm, distance: formatDistance(selectedLocation.distanceKm, language, distanceUnit),
}) })
: t("location.currentSourceGlobal", { location: selectedLocation.name })} : t("location.currentSourceGlobal", { location: selectedLocation.name })}
</p> </p>

View File

@@ -25,6 +25,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const selectStation = useWeatherStore((state) => state.selectStation); const selectStation = useWeatherStore((state) => state.selectStation);
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
const favorite = favorites.includes(station.id); const favorite = favorites.includes(station.id);
const mood = getWeatherMoodFromData(station); const mood = getWeatherMoodFromData(station);
const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit); const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
@@ -77,7 +78,7 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Gauge className="size-3" /> <Gauge className="size-3" />
{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]} {station.pressure === null ? "—" : formatPressure(station.pressure, language, pressureUnit)}
</span> </span>
</Link> </Link>
</Card> </Card>

View File

@@ -6,6 +6,7 @@ import { AlertTriangle, Droplets, Gauge, MapPin, Navigation, Umbrella, Wind } fr
import { import {
calculateFeelsLike, calculateFeelsLike,
formatDateTime, formatDateTime,
formatDistance,
formatHumidity, formatHumidity,
formatPressure, formatPressure,
formatRainfall, formatRainfall,
@@ -49,6 +50,9 @@ export function WeatherHero({
const { language, t } = useI18n(); const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit); const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit); const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null); const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
const displayedLocationName = locationName ?? station.name; const displayedLocationName = locationName ?? station.name;
const hasGlobalModel = currentWeather?.coverage === "global-model"; const hasGlobalModel = currentWeather?.coverage === "global-model";
@@ -61,6 +65,7 @@ export function WeatherHero({
distanceKm !== undefined && distanceKm !== undefined &&
distanceKm !== null && distanceKm !== null &&
distanceKm >= 30; distanceKm >= 30;
const formattedDistance = formatDistance(distanceKm ?? null, language, distanceUnit);
const displayedStation = currentWeather const displayedStation = currentWeather
? { ? {
...station, ...station,
@@ -91,9 +96,13 @@ export function WeatherHero({
: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined
? t("weather.rainfall10m") ? t("weather.rainfall10m")
: t("weather.rainfallTotal"), : 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 = const effectPrecipitation10m =
devEffectOverride === "rain" || devEffectOverride === "thunderstorm" devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
@@ -149,16 +158,16 @@ export function WeatherHero({
: hasFullHybridAnalysis : hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName }) ? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis : hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 }) ? t("location.heroHybridPartial", { station: station.name, distance: formattedDistance })
: locationName : locationName
? t("location.heroStationFallbackWithDistance", { ? t("location.heroStationFallbackWithDistance", {
station: station.name, station: station.name,
distance: distanceKm ?? 0, distance: formattedDistance,
}) })
: t("location.heroStationFallback", { station: station.name })} : t("location.heroStationFallback", { station: station.name })}
</p> </p>
{hasFullHybridAnalysis && locationName && ( {hasFullHybridAnalysis && locationName && (
<p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p> <p>{t("location.heroNearestStation", { station: station.name, distance: formattedDistance })}</p>
)} )}
{hasDistantFallback && ( {hasDistantFallback && (
<p className="flex items-start gap-1.5 text-warning"> <p className="flex items-start gap-1.5 text-warning">

View File

@@ -52,8 +52,7 @@ Subskrypcja Web Push zapisuje:
- czy aktywny jest brief na jutro, - czy aktywny jest brief na jutro,
- współrzędne i nazwę lokalizacji, - współrzędne i nazwę lokalizacji,
- opcjonalny powiat TERYT, - opcjonalny powiat TERYT,
- wybraną jednostkę temperatury dla briefów wysyłanych z serwera, - wybrane jednostki prezentacyjne dla briefów wysyłanych z serwera: temperaturę, wiatr, opad, ciśnienie i dystans.
- wybraną jednostkę wiatru dla briefów wysyłanych z serwera.
Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki. Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.

View File

@@ -120,7 +120,7 @@ Pole `Cloud` z IMGW Hybrid jest używane jako opis nieba:
| `>= 25` | Częściowe zachmurzenie | | `>= 25` | Częściowe zachmurzenie |
| `< 25` | brak osobnego opisu zachmurzenia | | `< 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 ## Mood i Efekty Wizualne

View File

@@ -3,11 +3,13 @@ import { translate } from "@/lib/i18n";
import { import {
DEFAULT_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT, DEFAULT_WIND_SPEED_UNIT,
DEFAULT_PRECIPITATION_UNIT,
formatPrecipitation,
formatTemperature, formatTemperature,
formatWindSpeed, formatWindSpeed,
} from "@/lib/weather-utils"; } from "@/lib/weather-utils";
import type { DailyForecast, HourlyForecast } from "@/types/forecast"; 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 { export function getForecastConditionKey(code: number | null): TranslationKey {
if (code === 0) return "forecast.condition.clear"; if (code === 0) return "forecast.condition.clear";
@@ -47,9 +49,13 @@ export function formatForecastTemperature(
return formatTemperature(value, language, unit); 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 "—"; 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( export function formatForecastWind(

View File

@@ -54,6 +54,27 @@ const translations = {
"settings.units.wind.ms": "m/s", "settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h", "settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph", "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.title": "Powiadomienia o ostrzeżeniach meteo",
"settings.notifications.description": "settings.notifications.description":
"Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.", "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.nearest": "Najbliższa stacja IMGW",
"location.modelSource": "Źródło modelowe", "location.modelSource": "Źródło modelowe",
"location.currentSource": "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.currentSourceGlobal":
"{location}: współrzędne są używane dla modelowych warunków bieżących i prognozy Open-Meteo.", "{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.heroHybridSource": "Analiza IMGW Hybrid dla lokalizacji: {location}",
"location.heroGlobalModelSource": "Modelowe warunki bieżące Open-Meteo 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.heroHybridLoading": "Pobieram lokalną analizę IMGW Hybrid. Tymczasowo pokazuję odczyt stacji: {station}.",
"location.heroHybridPartial": "location.heroHybridPartial":
"Lokalna analiza opadu IMGW Hybrid. Pozostałe parametry zastępczo ze stacji 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} km", "location.heroNearestStation": "Najbliższa stacja pomiarowa IMGW: {station} · około {distance}",
"location.heroStationFallback": "Dane zastępcze ze stacji IMGW: {station}", "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.heroDistantFallback": "Stacja jest oddalona od lokalizacji. Lokalne warunki mogą się różnić.",
"location.attribution": "Wyszukiwanie miejscowości:", "location.attribution": "Wyszukiwanie miejscowości:",
"location.gpsUse": "Użyj mojej lokalizacji", "location.gpsUse": "Użyj mojej lokalizacji",
@@ -344,6 +365,27 @@ const translations = {
"settings.units.wind.ms": "m/s", "settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h", "settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph", "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.title": "Weather warning notifications",
"settings.notifications.description": "settings.notifications.description":
"Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.", "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.nearest": "Nearest IMGW station",
"location.modelSource": "Model source", "location.modelSource": "Model source",
"location.currentSource": "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.heroHybridSource": "IMGW Hybrid analysis for: {location}",
"location.currentSourceGlobal": "location.currentSourceGlobal":
"{location}: coordinates are used for Open-Meteo model current conditions and forecast.", "{location}: coordinates are used for Open-Meteo model current conditions and forecast.",
@@ -418,11 +460,11 @@ const translations = {
"location.heroHybridLoading": "location.heroHybridLoading":
"Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.", "Loading local IMGW Hybrid analysis. Temporarily showing the station reading: {station}.",
"location.heroHybridPartial": "location.heroHybridPartial":
"Local IMGW Hybrid rainfall analysis. Other parameters use fallback data from IMGW 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} km away", "location.heroNearestStation": "Nearest IMGW measurement station: {station} · approximately {distance} away",
"location.heroStationFallback": "Fallback data from IMGW station: {station}", "location.heroStationFallback": "Fallback data from IMGW station: {station}",
"location.heroStationFallbackWithDistance": "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.heroDistantFallback": "The station is far from this place. Local conditions may differ.",
"location.attribution": "Place search:", "location.attribution": "Place search:",
"location.gpsUse": "Use my location", "location.gpsUse": "Use my location",

View File

@@ -1,6 +1,6 @@
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province"; 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"; import type { WeatherRegion } from "@/types/weather-region";
interface VapidKeyResponse { interface VapidKeyResponse {
@@ -26,6 +26,9 @@ interface SavePushSubscriptionOptions {
countyTeryt?: string | null; countyTeryt?: string | null;
temperatureUnit?: TemperatureUnit; temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit; windSpeedUnit?: WindSpeedUnit;
precipitationUnit?: PrecipitationUnit;
pressureUnit?: PressureUnit;
distanceUnit?: DistanceUnit;
} }
export async function savePushSubscription( export async function savePushSubscription(
@@ -52,6 +55,9 @@ export async function savePushSubscription(
countyTeryt: options.countyTeryt ?? null, countyTeryt: options.countyTeryt ?? null,
temperatureUnit: options.temperatureUnit, temperatureUnit: options.temperatureUnit,
windSpeedUnit: options.windSpeedUnit, windSpeedUnit: options.windSpeedUnit,
precipitationUnit: options.precipitationUnit,
pressureUnit: options.pressureUnit,
distanceUnit: options.distanceUnit,
}), }),
}); });
if (!response.ok) throw new Error("Unable to save push subscription."); if (!response.ok) throw new Error("Unable to save push subscription.");

View File

@@ -2,8 +2,14 @@ import Database from "better-sqlite3";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { import {
DEFAULT_DISTANCE_UNIT,
DEFAULT_PRECIPITATION_UNIT,
DEFAULT_PRESSURE_UNIT,
DEFAULT_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT, DEFAULT_WIND_SPEED_UNIT,
isDistanceUnit,
isPrecipitationUnit,
isPressureUnit,
isTemperatureUnit, isTemperatureUnit,
isWindSpeedUnit, isWindSpeedUnit,
} from "@/lib/weather-utils"; } from "@/lib/weather-utils";
@@ -31,6 +37,9 @@ interface PushSubscriptionRow {
county_teryt: string | null; county_teryt: string | null;
temperature_unit: string | null; temperature_unit: string | null;
wind_speed_unit: string | null; wind_speed_unit: string | null;
precipitation_unit: string | null;
pressure_unit: string | null;
distance_unit: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -71,6 +80,9 @@ function initializeDatabase(database: Database.Database) {
county_teryt TEXT, county_teryt TEXT,
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}', temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_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, created_at TEXT NOT NULL,
updated_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")) { if (!columns.some((column) => column.name === "timezone")) {
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run(); 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() { function getDatabase() {
@@ -139,6 +172,11 @@ function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription |
countyTeryt: row.county_teryt, countyTeryt: row.county_teryt,
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT, 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, 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, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -167,6 +205,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
county_teryt, county_teryt,
temperature_unit, temperature_unit,
wind_speed_unit, wind_speed_unit,
precipitation_unit,
pressure_unit,
distance_unit,
created_at, created_at,
updated_at updated_at
) VALUES ( ) VALUES (
@@ -185,6 +226,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
@countyTeryt, @countyTeryt,
@temperatureUnit, @temperatureUnit,
@windSpeedUnit, @windSpeedUnit,
@precipitationUnit,
@pressureUnit,
@distanceUnit,
@createdAt, @createdAt,
@updatedAt @updatedAt
) )
@@ -203,6 +247,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
county_teryt = excluded.county_teryt, county_teryt = excluded.county_teryt,
temperature_unit = excluded.temperature_unit, temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_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 updated_at = excluded.updated_at
`, `,
) )
@@ -222,6 +269,9 @@ export function upsertPushSubscription(subscription: WarningPushSubscription) {
countyTeryt: subscription.countyTeryt, countyTeryt: subscription.countyTeryt,
temperatureUnit: subscription.temperatureUnit, temperatureUnit: subscription.temperatureUnit,
windSpeedUnit: subscription.windSpeedUnit, windSpeedUnit: subscription.windSpeedUnit,
precipitationUnit: subscription.precipitationUnit,
pressureUnit: subscription.pressureUnit,
distanceUnit: subscription.distanceUnit,
createdAt: subscription.createdAt, createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt, updatedAt: subscription.updatedAt,
}); });

View File

@@ -4,7 +4,7 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { SelectedLocation } from "@/types/location"; import type { SelectedLocation } from "@/types/location";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units"; import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
import { import {
DEFAULT_APP_SECTION_VISIBILITY, DEFAULT_APP_SECTION_VISIBILITY,
normalizeAppSectionVisibility, normalizeAppSectionVisibility,
@@ -17,7 +17,18 @@ import {
type DashboardSectionId, type DashboardSectionId,
type DashboardSectionVisibility, type DashboardSectionVisibility,
} from "@/lib/dashboard-sections"; } 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 type { WeatherRegion } from "@/types/weather-region";
import { getWeatherRegionForCountryCode } from "@/types/weather-region"; import { getWeatherRegionForCountryCode } from "@/types/weather-region";
@@ -34,6 +45,9 @@ interface WeatherStore {
warningNotificationProvince: Province | null; warningNotificationProvince: Province | null;
temperatureUnit: TemperatureUnit; temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit; windSpeedUnit: WindSpeedUnit;
precipitationUnit: PrecipitationUnit;
pressureUnit: PressureUnit;
distanceUnit: DistanceUnit;
dashboardSections: DashboardSectionVisibility; dashboardSections: DashboardSectionVisibility;
appSections: AppSectionVisibility; appSections: AppSectionVisibility;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
@@ -46,6 +60,9 @@ interface WeatherStore {
setWarningNotificationProvince: (province: Province | null) => void; setWarningNotificationProvince: (province: Province | null) => void;
setTemperatureUnit: (unit: TemperatureUnit) => void; setTemperatureUnit: (unit: TemperatureUnit) => void;
setWindSpeedUnit: (unit: WindSpeedUnit) => void; setWindSpeedUnit: (unit: WindSpeedUnit) => void;
setPrecipitationUnit: (unit: PrecipitationUnit) => void;
setPressureUnit: (unit: PressureUnit) => void;
setDistanceUnit: (unit: DistanceUnit) => void;
setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void; setDashboardSectionVisible: (section: DashboardSectionId, visible: boolean) => void;
setAppSectionVisible: (section: AppSectionId, visible: boolean) => void; setAppSectionVisible: (section: AppSectionId, visible: boolean) => void;
} }
@@ -63,6 +80,9 @@ export const useWeatherStore = create<WeatherStore>()(
warningNotificationProvince: null, warningNotificationProvince: null,
temperatureUnit: DEFAULT_TEMPERATURE_UNIT, temperatureUnit: DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT, windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
precipitationUnit: DEFAULT_PRECIPITATION_UNIT,
pressureUnit: DEFAULT_PRESSURE_UNIT,
distanceUnit: DEFAULT_DISTANCE_UNIT,
dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY, dashboardSections: DEFAULT_DASHBOARD_SECTION_VISIBILITY,
appSections: DEFAULT_APP_SECTION_VISIBILITY, appSections: DEFAULT_APP_SECTION_VISIBILITY,
toggleFavorite: (id) => toggleFavorite: (id) =>
@@ -80,6 +100,9 @@ export const useWeatherStore = create<WeatherStore>()(
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setTemperatureUnit: (unit) => set({ temperatureUnit: unit }), setTemperatureUnit: (unit) => set({ temperatureUnit: unit }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }), setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
setPrecipitationUnit: (unit) => set({ precipitationUnit: unit }),
setPressureUnit: (unit) => set({ pressureUnit: unit }),
setDistanceUnit: (unit) => set({ distanceUnit: unit }),
setDashboardSectionVisible: (section, visible) => setDashboardSectionVisible: (section, visible) =>
set((state) => ({ set((state) => ({
dashboardSections: { ...state.dashboardSections, [section]: visible }, dashboardSections: { ...state.dashboardSections, [section]: visible },
@@ -100,11 +123,21 @@ export const useWeatherStore = create<WeatherStore>()(
if (!state) return persisted; if (!state) return persisted;
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
const appSections = normalizeAppSectionVisibility(state.appSections); 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 countryCode = location.countryCode ?? (location.province ? "PL" : null);
const region = location.region ?? getWeatherRegionForCountryCode(countryCode); const region = location.region ?? getWeatherRegionForCountryCode(countryCode);
return { return {
...state, ...state,
...unitPreferences,
dashboardSections, dashboardSections,
appSections, appSections,
selectedLocation: { selectedLocation: {

View File

@@ -8,10 +8,12 @@ import { warningMatchesCounty } from "@/lib/warning-regions";
import { import {
DEFAULT_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT, DEFAULT_WIND_SPEED_UNIT,
DEFAULT_PRECIPITATION_UNIT,
formatPrecipitation as formatDisplayPrecipitation,
formatTemperature as formatDisplayTemperature, formatTemperature as formatDisplayTemperature,
formatWindSpeed, formatWindSpeed,
} from "@/lib/weather-utils"; } 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"; export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -33,19 +35,10 @@ interface BuildWeatherBriefInput {
language: Language; language: Language;
temperatureUnit?: TemperatureUnit; temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit; windSpeedUnit?: WindSpeedUnit;
precipitationUnit?: PrecipitationUnit;
now?: Date; 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) { function formatHour(value: string) {
return value.slice(11, 16); return value.slice(11, 16);
} }
@@ -237,6 +230,7 @@ export function buildWeatherBrief({
language, language,
temperatureUnit = DEFAULT_TEMPERATURE_UNIT, temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
precipitationUnit = DEFAULT_PRECIPITATION_UNIT,
now = new Date(), now = new Date(),
}: BuildWeatherBriefInput): WeatherBrief | null { }: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24); const hours = getUpcomingHours(forecast.hourly, now, 24);
@@ -313,8 +307,8 @@ export function buildWeatherBrief({
: ""; : "";
body.push( body.push(
language === "pl" language === "pl"
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${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" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`, : `Rainfall: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
); );
} }
if (maximumWind !== null) { if (maximumWind !== null) {
@@ -371,6 +365,7 @@ export function buildTomorrowWeatherBrief({
language, language,
temperatureUnit = DEFAULT_TEMPERATURE_UNIT, temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
precipitationUnit = DEFAULT_PRECIPITATION_UNIT,
now = new Date(), now = new Date(),
}: BuildWeatherBriefInput): WeatherBrief | null { }: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1); const targetDateKey = getWarsawDateKey(now, 1);
@@ -453,8 +448,8 @@ export function buildTomorrowWeatherBrief({
: ""; : "";
body.push( body.push(
language === "pl" language === "pl"
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${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" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`, : `Precipitation: ${rainfallTotal === null ? "not fully available" : formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
); );
} else { } else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast."); body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
@@ -469,7 +464,7 @@ export function buildTomorrowWeatherBrief({
const rainPushPart = const rainPushPart =
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
? formatRainfall(rainfallTotal, language) ? formatDisplayPrecipitation(rainfallTotal, language, precipitationUnit)
: null; : null;
const pushParts = [ const pushParts = [
`${locationName}:`, `${locationName}:`,

View File

@@ -11,13 +11,19 @@ import type {
import { translate, type Language } from "@/lib/i18n"; import { translate, type Language } from "@/lib/i18n";
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces"; import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
import type { CurrentWeatherCondition } from "@/types/imgw-current"; 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<Language, string> = { pl: "pl-PL", en: "en-GB" }; const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
export const DEFAULT_TEMPERATURE_UNIT: TemperatureUnit = "c"; export const DEFAULT_TEMPERATURE_UNIT: TemperatureUnit = "c";
export const TEMPERATURE_UNITS: TemperatureUnit[] = ["c", "f"]; export const TEMPERATURE_UNITS: TemperatureUnit[] = ["c", "f"];
export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "kmh"; 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<TemperatureUnit, string> = { const temperatureUnitLabels: Record<TemperatureUnit, string> = {
c: "°C", c: "°C",
@@ -28,6 +34,26 @@ const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
ms: "m/s", ms: "m/s",
kmh: "km/h", kmh: "km/h",
mph: "mph", mph: "mph",
kt: "kt",
bft: "Bft",
};
const precipitationUnitLabels: Record<PrecipitationUnit, string> = {
mm: "mm",
cm: "cm",
in: "in",
};
const pressureUnitLabels: Record<PressureUnit, string> = {
hpa: "hPa",
kpa: "kPa",
inhg: "inHg",
mmhg: "mm Hg",
};
const distanceUnitLabels: Record<DistanceUnit, string> = {
km: "km",
mi: "mi",
}; };
export function toNumber(value: unknown): number | null { export function toNumber(value: unknown): number | null {
@@ -152,10 +178,6 @@ export function formatTemperature(
: formatTemperatureValue(convertTemperature(value, unit), language, unit); : 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") { export function formatHumidity(value: number | null, language: Language = "pl") {
return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`; return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`;
} }
@@ -168,18 +190,60 @@ export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
return windSpeedUnitLabels[unit]; 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) { export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
if (unit === "kmh") return speed * 3.6; if (unit === "kmh") return speed * 3.6;
if (unit === "mph") return speed * 2.2369362921; if (unit === "mph") return speed * 2.2369362921;
if (unit === "kt") return speed * 1.9438444924;
return speed; 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( export function formatWindSpeed(
speed: number | null, speed: number | null,
language: Language = "pl", language: Language = "pl",
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
) { ) {
if (speed === null) return translate(language, "common.noData"); if (speed === null) return translate(language, "common.noData");
if (unit === "bft") return `${convertWindSpeedToBeaufort(speed)} ${getWindSpeedUnitLabel(unit)}`;
const convertedSpeed = convertWindSpeed(speed, unit); const convertedSpeed = convertWindSpeed(speed, unit);
const digits = unit === "ms" ? 1 : 0; const digits = unit === "ms" ? 1 : 0;
const formattedSpeed = new Intl.NumberFormat(locales[language], { const formattedSpeed = new Intl.NumberFormat(locales[language], {
@@ -200,8 +264,73 @@ export function formatWind(
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`; return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
} }
export function formatRainfall(value: number | null, language: Language = "pl") { export function formatRainfall(
return value === null ? translate(language, "common.noData") : `${value.toFixed(value < 1 ? 2 : 1)} mm`; 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") { export function formatWaterLevel(value: number | null, language: Language = "pl") {

View File

@@ -54,10 +54,16 @@ function warning(overrides: Partial<WeatherWarning> = {}): WeatherWarning {
} }
describe("weather briefs", () => { 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({ const brief = buildWeatherBrief({
forecast: forecast([ 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 }), hour({ time: "2026-06-14T09:00", temperature: 12, windSpeed: 5, precipitationProbability: 30 }),
]), ]),
warnings: [], warnings: [],
@@ -67,6 +73,7 @@ describe("weather briefs", () => {
language: "en", language: "en",
temperatureUnit: "f", temperatureUnit: "f",
windSpeedUnit: "mph", windSpeedUnit: "mph",
precipitationUnit: "in",
now: new Date("2026-06-14T06:00:00.000Z"), 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("50°F-54°F");
expect(brief?.pushBody).toContain("wind 11 mph"); expect(brief?.pushBody).toContain("wind 11 mph");
expect(brief?.pushBody).not.toContain("km/h"); 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", () => { it("warns about tomorrow storms from model weather codes without IMGW warning", () => {
const brief = buildTomorrowWeatherBrief({ const brief = buildTomorrowWeatherBrief({
forecast: forecast([ forecast: forecast([
hour({ time: "2026-06-15T08:00", temperature: 14, weatherCode: 3, precipitationProbability: 20 }), 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: [], warnings: [],
province: "mazowieckie", province: "mazowieckie",
countyTeryt: "1465", countyTeryt: "1465",
locationName: "Warsaw", locationName: "Warsaw",
language: "en", language: "en",
precipitationUnit: "in",
now: new Date("2026-06-14T16:00:00.000Z"), now: new Date("2026-06-14T16:00:00.000Z"),
}); });
expect(brief?.severity).toBe("warning"); expect(brief?.severity).toBe("warning");
expect(brief?.headline).toBe("Thunderstorms are possible tomorrow"); expect(brief?.headline).toBe("Thunderstorms are possible tomorrow");
expect(brief?.pushBody).toContain("thunderstorms"); expect(brief?.pushBody).toContain("thunderstorms");
expect(brief?.pushBody).toContain("1.00 in");
expect(brief?.pushBody).not.toContain("IMGW"); expect(brief?.pushBody).not.toContain("IMGW");
}); });

View File

@@ -1,9 +1,18 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEFAULT_DISTANCE_UNIT,
DEFAULT_PRECIPITATION_UNIT,
DEFAULT_PRESSURE_UNIT,
DEFAULT_WIND_SPEED_UNIT, DEFAULT_WIND_SPEED_UNIT,
formatDistance,
formatPrecipitation,
formatPressure,
formatTemperature, formatTemperature,
formatWindSpeed, formatWindSpeed,
isDistanceUnit,
isPrecipitationUnit,
isPressureUnit,
isTemperatureUnit, isTemperatureUnit,
isWindSpeedUnit, isWindSpeedUnit,
} from "@/lib/weather-utils"; } from "@/lib/weather-utils";
@@ -18,12 +27,40 @@ describe("weather utilities", () => {
expect(formatTemperature(10, "en", "f")).toBe("50°F"); expect(formatTemperature(10, "en", "f")).toBe("50°F");
expect(formatWindSpeed(4, "en", "mph")).toBe("9 mph"); expect(formatWindSpeed(4, "en", "mph")).toBe("9 mph");
expect(formatWindSpeed(4, "en", "ms")).toBe("4.0 m/s"); 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", () => { it("validates supported presentation units", () => {
expect(isTemperatureUnit("c")).toBe(true); expect(isTemperatureUnit("c")).toBe(true);
expect(isTemperatureUnit("kelvin")).toBe(false); expect(isTemperatureUnit("kelvin")).toBe(false);
expect(isWindSpeedUnit("kmh")).toBe(true); expect(isWindSpeedUnit("kmh")).toBe(true);
expect(isWindSpeedUnit("kt")).toBe(true);
expect(isWindSpeedUnit("bft")).toBe(true);
expect(isWindSpeedUnit("knots")).toBe(false); 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);
}); });
}); });

View File

@@ -1,6 +1,6 @@
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { Province } from "@/types/province"; 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"; import type { WeatherRegion } from "@/types/weather-region";
export interface PushSubscriptionKeys { export interface PushSubscriptionKeys {
@@ -30,6 +30,9 @@ export interface WarningPushSubscription {
countyTeryt: string | null; countyTeryt: string | null;
temperatureUnit: TemperatureUnit; temperatureUnit: TemperatureUnit;
windSpeedUnit: WindSpeedUnit; windSpeedUnit: WindSpeedUnit;
precipitationUnit: PrecipitationUnit;
pressureUnit: PressureUnit;
distanceUnit: DistanceUnit;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -1,2 +1,5 @@
export type TemperatureUnit = "c" | "f"; 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";