Compare commits

...

5 Commits

Author SHA1 Message Date
zv
2c2515abba fix: show offline status in pwa
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 11m21s
2026-07-04 20:53:13 +02:00
zv
6bec7060e0 fix: cache pwa static assets offline
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
2026-07-04 20:41:19 +02:00
zv
c7da9e0d94 feat: allow dashboard section reordering 2026-07-04 20:31:56 +02:00
zv
91acdb39b8 feat: add weather unit preferences
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
2026-07-04 20:16:11 +02:00
zv
ab6b7b414f copy: simplify user-facing descriptions 2026-07-04 16:04:39 +02:00
34 changed files with 986 additions and 188 deletions

View File

@@ -54,7 +54,8 @@ 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. - Sekcje dashboardu pod główną kartą pogody definiuj centralnie w `lib/dashboard-sections.ts`; widoczność i kolejność zapisuj w store, a komponent dashboardu renderuj według znormalizowanej kolejności.
- 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

@@ -19,6 +19,11 @@ import { locateSynopStations } from "@/lib/location-utils";
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings"; import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card"; import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import {
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
type DashboardSectionId,
} from "@/lib/dashboard-sections";
export function DashboardPage() { export function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
@@ -26,7 +31,8 @@ export function DashboardPage() {
const { data: positions = [] } = useMeteoStationPositions(); const { data: positions = [] } = useMeteoStationPositions();
const selectedStationId = useWeatherStore((state) => state.selectedStationId); const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation); const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const dashboardSections = useWeatherStore((state) => state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
const selectedStation = const selectedStation =
stations?.find((station) => station.id === selectedStationId) ?? stations?.find((station) => station.id === selectedStationId) ??
stations?.find((station) => station.name === DEFAULT_STATION_NAME) ?? stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
@@ -73,6 +79,34 @@ export function DashboardPage() {
} }
: selectedStation; : selectedStation;
const renderDashboardSection = (sectionId: DashboardSectionId) => {
if (!dashboardSections[sectionId]) return null;
if (sectionId === "warnings") return <DashboardWarnings key={sectionId} />;
if (sectionId === "weatherBrief")
return (
<WeatherBriefCard
key={sectionId}
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
);
if (sectionId === "forecast")
return (
<ForecastPanel
key={sectionId}
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
);
if (sectionId === "favorites") return <FavoritesSection key={sectionId} stations={stations} />;
if (sectionId === "featuredStations") return <FeaturedStationsSection key={sectionId} stations={stations} />;
return null;
};
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
@@ -84,23 +118,7 @@ export function DashboardPage() {
locationName={activeLocation?.name} locationName={activeLocation?.name}
distanceKm={activeLocation?.distanceKm} distanceKm={activeLocation?.distanceKm}
/> />
<DashboardWarnings /> {dashboardSectionOrder.map(renderDashboardSection)}
{dashboardSections.weatherBrief && (
<WeatherBriefCard
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
)}
<ForecastPanel
latitude={forecastLatitude}
longitude={forecastLongitude}
region={activeRegion}
locationName={forecastLocationName ?? selectedStation.name}
/>
<FavoritesSection stations={stations} />
{dashboardSections.featuredStations && <FeaturedStationsSection stations={stations} />}
</div> </div>
); );
} }

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

@@ -32,7 +32,7 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
> >
Open-Meteo <ExternalLink className="size-3" /> Open-Meteo <ExternalLink className="size-3" />
</a> </a>
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")} .
</p> </p>
); );
} }

View File

@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { CloudSun, Droplets, Settings, TriangleAlert } from "lucide-react"; import { useEffect, useState } from "react";
import { CloudSun, Droplets, Settings, TriangleAlert, WifiOff } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { isAppNavItemVisible } from "@/lib/app-sections"; import { isAppNavItemVisible } from "@/lib/app-sections";
import { NAV_ITEMS } from "@/lib/constants"; import { NAV_ITEMS } from "@/lib/constants";
@@ -23,6 +24,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const { t } = useI18n(); const { t } = useI18n();
const appSections = useWeatherStore((state) => state.appSections); const appSections = useWeatherStore((state) => state.appSections);
const visibleNavItems = NAV_ITEMS.filter((item) => isAppNavItemVisible(item.href, appSections)); const visibleNavItems = NAV_ITEMS.filter((item) => isAppNavItemVisible(item.href, appSections));
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
const updateConnectionStatus = () => setIsOffline(!navigator.onLine);
updateConnectionStatus();
window.addEventListener("online", updateConnectionStatus);
window.addEventListener("offline", updateConnectionStatus);
return () => {
window.removeEventListener("online", updateConnectionStatus);
window.removeEventListener("offline", updateConnectionStatus);
};
}, []);
return ( return (
<div className="min-h-screen overflow-x-hidden bg-background"> <div className="min-h-screen overflow-x-hidden bg-background">
@@ -56,6 +69,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</div> </div>
</header> </header>
{isOffline && (
<div className="border-b border-warning/25 bg-warning/10 px-4 py-2 text-sm font-medium text-warning sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl items-center gap-2">
<WifiOff className="size-4 shrink-0" aria-hidden="true" />
<span>{t("offline.banner")}</span>
</div>
</div>
)}
<main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main> <main className="app-main-safe mx-auto max-w-7xl px-4 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
<nav <nav
aria-label={t("nav.mobile")} aria-label={t("nav.mobile")}

View File

@@ -2,14 +2,19 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
ArrowDown,
ArrowUp,
Bell, Bell,
BellRing, BellRing,
ChevronDown, ChevronDown,
Clock3, Clock3,
CloudRain,
Gauge,
Languages, Languages,
LayoutDashboard, LayoutDashboard,
MapPinned, MapPinned,
Palette, Palette,
Ruler,
Settings, Settings,
Smartphone, Smartphone,
Thermometer, Thermometer,
@@ -24,7 +29,11 @@ import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { useWeatherStations } from "@/hooks/use-weather-stations"; import { useWeatherStations } from "@/hooks/use-weather-stations";
import { APP_SECTION_SETTINGS } from "@/lib/app-sections"; import { APP_SECTION_SETTINGS } from "@/lib/app-sections";
import { DEFAULT_STATION_ID } from "@/lib/constants"; import { DEFAULT_STATION_ID } from "@/lib/constants";
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections"; import {
DASHBOARD_SECTION_SETTINGS,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
} from "@/lib/dashboard-sections";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { locateSynopStations } from "@/lib/location-utils"; import { locateSynopStations } from "@/lib/location-utils";
import { import {
@@ -36,8 +45,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 +169,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 +187,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,7 +233,11 @@ 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 dashboardSections = useWeatherStore((state) => state.dashboardSections); const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
const pressureUnit = useWeatherStore((state) => state.pressureUnit);
const distanceUnit = useWeatherStore((state) => state.distanceUnit);
const dashboardSections = normalizeDashboardSectionVisibility(useWeatherStore((state) => state.dashboardSections));
const dashboardSectionOrder = normalizeDashboardSectionOrder(useWeatherStore((state) => state.dashboardSectionOrder));
const appSections = useWeatherStore((state) => state.appSections); const appSections = useWeatherStore((state) => state.appSections);
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
@@ -194,7 +246,11 @@ 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 moveDashboardSection = useWeatherStore((state) => state.moveDashboardSection);
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible); const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
const selectedStation = const selectedStation =
@@ -229,6 +285,9 @@ export function SettingsPage() {
? formatProvinceName(effectiveProvince, language) ? formatProvinceName(effectiveProvince, language)
: t("settings.notifications.noProvince"); : t("settings.notifications.noProvince");
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie"; const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
const orderedDashboardSections = dashboardSectionOrder
.map((sectionId) => DASHBOARD_SECTION_SETTINGS.find((section) => section.id === sectionId))
.filter((section): section is (typeof DASHBOARD_SECTION_SETTINGS)[number] => Boolean(section));
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@@ -268,6 +327,9 @@ export function SettingsPage() {
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
temperatureUnit, temperatureUnit,
windSpeedUnit, windSpeedUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
}).catch(() => undefined); }).catch(() => undefined);
} }
@@ -289,6 +351,9 @@ export function SettingsPage() {
notificationTimezone, notificationTimezone,
selectedRegion, selectedRegion,
temperatureUnit, temperatureUnit,
precipitationUnit,
pressureUnit,
distanceUnit,
tomorrowBriefEnabled, tomorrowBriefEnabled,
vapidPublicKey, vapidPublicKey,
windSpeedUnit, windSpeedUnit,
@@ -376,6 +441,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 +512,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 +541,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 +628,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">
@@ -567,17 +725,58 @@ export function SettingsPage() {
</div> </div>
</div> </div>
<div className="mt-2"> <div className="mt-4 space-y-2">
{DASHBOARD_SECTION_SETTINGS.map((section) => ( {orderedDashboardSections.map((section, index) => {
<NotificationSwitchLabel const sectionTitle = t(section.titleKey);
key={section.id} const checked = dashboardSections[section.id];
icon={LayoutDashboard} return (
title={t(section.titleKey)} <div
description={t(section.descriptionKey)} key={section.id}
checked={dashboardSections[section.id]} className={`rounded-card border px-3 py-3 transition-colors ${
onChange={(checked) => setDashboardSectionVisible(section.id, checked)} checked ? "border-accent/35 bg-accent/5" : "border-border/60 bg-surface"
/> }`}
))} >
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-4">
<span className="min-w-0">
<span className="block text-sm font-semibold">{sectionTitle}</span>
<span className="mt-1 block text-sm leading-6 text-muted">{t(section.descriptionKey)}</span>
</span>
<input
type="checkbox"
checked={checked}
onChange={(event) => setDashboardSectionVisible(section.id, event.target.checked)}
className="sr-only"
/>
<SwitchIndicator checked={checked} />
</label>
<div className="flex items-center gap-2 self-end sm:self-center" aria-label={sectionTitle}>
<Button
type="button"
variant="icon"
className="size-9"
disabled={index === 0}
aria-label={t("settings.dashboardSections.moveUp", { section: sectionTitle })}
onClick={() => moveDashboardSection(section.id, -1)}
>
<ArrowUp className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="icon"
className="size-9"
disabled={index === orderedDashboardSections.length - 1}
aria-label={t("settings.dashboardSections.moveDown", { section: sectionTitle })}
onClick={() => moveDashboardSection(section.id, 1)}
>
<ArrowDown className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
</div>
);
})}
</div> </div>
</Card> </Card>
@@ -801,7 +1000,9 @@ export function SettingsPage() {
</Button> </Button>
</div> </div>
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p> {t("settings.notifications.implementationNote") && (
<p className="mt-4 text-xs leading-5 text-muted">{t("settings.notifications.implementationNote")}</p>
)}
</div> </div>
</div> </div>
</Card> </Card>

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

@@ -55,7 +55,8 @@ Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.j
Service worker: Service worker:
- cache'uje powłokę aplikacji, - cache'uje powłokę aplikacji,
- obsługuje fallback `/offline` dla nawigacji, - obsługuje fallback `/offline` dla nawigacji i zapisuje udane nawigacje do cache,
- cache'uje statyczne assety Next.js, style, skrypty, fonty, obrazy, manifest i ikony,
- cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`, - cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`,
- obsługuje zdarzenia `push` i kliknięcia w powiadomienia. - obsługuje zdarzenia `push` i kliknięcia w powiadomienia.

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

@@ -1,12 +1,30 @@
import type { TranslationKey } from "@/lib/i18n"; import type { TranslationKey } from "@/lib/i18n";
export const DASHBOARD_SECTION_SETTINGS = [ export const DASHBOARD_SECTION_SETTINGS = [
{
id: "warnings",
titleKey: "settings.dashboardSections.warnings.title",
descriptionKey: "settings.dashboardSections.warnings.description",
defaultVisible: true,
},
{ {
id: "weatherBrief", id: "weatherBrief",
titleKey: "settings.dashboardSections.weatherBrief.title", titleKey: "settings.dashboardSections.weatherBrief.title",
descriptionKey: "settings.dashboardSections.weatherBrief.description", descriptionKey: "settings.dashboardSections.weatherBrief.description",
defaultVisible: true, defaultVisible: true,
}, },
{
id: "forecast",
titleKey: "settings.dashboardSections.forecast.title",
descriptionKey: "settings.dashboardSections.forecast.description",
defaultVisible: true,
},
{
id: "favorites",
titleKey: "settings.dashboardSections.favorites.title",
descriptionKey: "settings.dashboardSections.favorites.description",
defaultVisible: true,
},
{ {
id: "featuredStations", id: "featuredStations",
titleKey: "settings.dashboardSections.featuredStations.title", titleKey: "settings.dashboardSections.featuredStations.title",
@@ -23,6 +41,11 @@ export const DASHBOARD_SECTION_SETTINGS = [
export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"]; export type DashboardSectionId = (typeof DASHBOARD_SECTION_SETTINGS)[number]["id"];
export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>; export type DashboardSectionVisibility = Record<DashboardSectionId, boolean>;
export type DashboardSectionOrder = DashboardSectionId[];
export const DEFAULT_DASHBOARD_SECTION_ORDER = DASHBOARD_SECTION_SETTINGS.map(
(section) => section.id,
) as DashboardSectionOrder;
export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce( export const DEFAULT_DASHBOARD_SECTION_VISIBILITY = DASHBOARD_SECTION_SETTINGS.reduce(
(visibility, section) => ({ (visibility, section) => ({
@@ -42,3 +65,14 @@ export function normalizeDashboardSectionVisibility(value: unknown): DashboardSe
{} as DashboardSectionVisibility, {} as DashboardSectionVisibility,
); );
} }
export function normalizeDashboardSectionOrder(value: unknown): DashboardSectionOrder {
const allowedIds = new Set<DashboardSectionId>(DASHBOARD_SECTION_SETTINGS.map((section) => section.id));
const stored = Array.isArray(value)
? value.filter((sectionId): sectionId is DashboardSectionId => allowedIds.has(sectionId as DashboardSectionId))
: [];
return [
...stored,
...DEFAULT_DASHBOARD_SECTION_ORDER.filter((sectionId) => !stored.includes(sectionId)),
] as DashboardSectionOrder;
}

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

@@ -21,18 +21,27 @@ const translations = {
"theme.dark": "Włącz ciemny motyw", "theme.dark": "Włącz ciemny motyw",
"settings.section": "Preferencje", "settings.section": "Preferencje",
"settings.title": "Ustawienia", "settings.title": "Ustawienia",
"settings.description": "settings.description": "Dostosuj wygląd, język, sekcje i powiadomienia.",
"Zarządzaj językiem, wyglądem i przygotowaniem powiadomień o ostrzeżeniach meteorologicznych IMGW.",
"settings.language.title": "Język", "settings.language.title": "Język",
"settings.language.description": "settings.language.description":
"Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.", "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
"settings.theme.title": "Motyw", "settings.theme.title": "Motyw",
"settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.", "settings.theme.description": "Przełącz jasny lub ciemny wygląd aplikacji na tym urządzeniu.",
"settings.dashboardSections.title": "Strona główna", "settings.dashboardSections.title": "Strona główna",
"settings.dashboardSections.description": "Wybierz, które dodatkowe sekcje mają być widoczne na dashboardzie.", "settings.dashboardSections.description": "Wybierz widoczność i kolejność sekcji pod główną kartą pogody.",
"settings.dashboardSections.moveUp": "Przenieś sekcję {section} wyżej",
"settings.dashboardSections.moveDown": "Przenieś sekcję {section} niżej",
"settings.dashboardSections.warnings.title": "Ostrzeżenia",
"settings.dashboardSections.warnings.description":
"Pokazuje aktywne i nadchodzące ostrzeżenia dla wybranej lokalizacji. Na stronie głównej sekcja pojawia się tylko wtedy, gdy jest aktywne ostrzeżenie meteo.",
"settings.dashboardSections.weatherBrief.title": "Brief dnia", "settings.dashboardSections.weatherBrief.title": "Brief dnia",
"settings.dashboardSections.weatherBrief.description": "settings.dashboardSections.weatherBrief.description":
"Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.", "Pokazuje deterministyczne podsumowanie dnia i krótką prognozę na jutro.",
"settings.dashboardSections.forecast.title": "Prognoza 7-dniowa",
"settings.dashboardSections.forecast.description":
"Pokazuje najbliższe godziny, prognozę tygodniową i wykresy dnia.",
"settings.dashboardSections.favorites.title": "Ulubione",
"settings.dashboardSections.favorites.description": "Pokazuje zapisane lokalizacje i stacje na stronie głównej.",
"settings.dashboardSections.featuredStations.title": "Szybki wybór", "settings.dashboardSections.featuredStations.title": "Szybki wybór",
"settings.dashboardSections.featuredStations.description": "settings.dashboardSections.featuredStations.description":
"Pokazuje listę popularnych lokalizacji na dole strony głównej.", "Pokazuje listę popularnych lokalizacji na dole strony głównej.",
@@ -55,6 +64,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.",
@@ -68,16 +98,12 @@ const translations = {
"Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.", "Oficjalne ostrzeżenia IMGW są obecnie obsługiwane tylko dla Polski. Dla tej lokalizacji możesz używać briefów opartych o prognozę modelową Open-Meteo.",
"settings.notifications.deviceTitle": "To urządzenie", "settings.notifications.deviceTitle": "To urządzenie",
"settings.notifications.enable": "Alerty IMGW", "settings.notifications.enable": "Alerty IMGW",
"settings.notifications.enableDescription": "settings.notifications.enableDescription": "Otrzymuj alerty dla wybranego obszaru.",
"Subskrypcja zostanie zapisana dla wybranego województwa i użyta przez serwerowy sprawdzacz ostrzeżeń.", "settings.notifications.enableGlobalDescription": "Otrzymuj briefy dla wybranej lokalizacji.",
"settings.notifications.enableGlobalDescription":
"Subskrypcja zostanie zapisana dla wybranej lokalizacji i użyta do briefów opartych o prognozę modelową.",
"settings.notifications.morningBrief": "Brief poranny o 7:00", "settings.notifications.morningBrief": "Brief poranny o 7:00",
"settings.notifications.morningBriefDescription": "settings.notifications.morningBriefDescription": "Krótkie podsumowanie pogody na dziś.",
"Używa wybranej lokalizacji i prognozy modelowej, aby wysłać krótkie podsumowanie dnia na to urządzenie.",
"settings.notifications.tomorrowBrief": "Brief na jutro o 18:00", "settings.notifications.tomorrowBrief": "Brief na jutro o 18:00",
"settings.notifications.tomorrowBriefDescription": "settings.notifications.tomorrowBriefDescription": "Wieczorna prognoza na kolejny dzień.",
"Wysyła wieczorem krótką prognozę na kolejny dzień, w tym sygnały burz i opadów z modelu.",
"settings.notifications.enableDevice": "Włącz na tym urządzeniu", "settings.notifications.enableDevice": "Włącz na tym urządzeniu",
"settings.notifications.disable": "Wyłącz na tym urządzeniu", "settings.notifications.disable": "Wyłącz na tym urządzeniu",
"settings.notifications.enabledStatus": "Włączone", "settings.notifications.enabledStatus": "Włączone",
@@ -92,7 +118,7 @@ const translations = {
"settings.notifications.testError": "Nie udało się wysłać powiadomienia testowego.", "settings.notifications.testError": "Nie udało się wysłać powiadomienia testowego.",
"settings.notifications.requestPermission": "Sprawdź zgodę", "settings.notifications.requestPermission": "Sprawdź zgodę",
"settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.", "settings.notifications.statusChecking": "Sprawdzam obsługę powiadomień w tej przeglądarce.",
"settings.notifications.statusUnconfigured": "Serwer nie ma jeszcze skonfigurowanych kluczy Web Push VAPID.", "settings.notifications.statusUnconfigured": "Powiadomienia są niedostępne.",
"settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.", "settings.notifications.statusUnsupported": "Ta przeglądarka nie udostępnia Web Push dla tej aplikacji.",
"settings.notifications.statusNeedsInstall": "settings.notifications.statusNeedsInstall":
"Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.", "Na iPhonie dodaj aplikację do ekranu początkowego przez Udostępnij → Dodaj do ekranu początkowego, a potem otwórz ją z ikony.",
@@ -100,8 +126,7 @@ const translations = {
"settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.", "settings.notifications.statusGranted": "To urządzenie ma zgodę na powiadomienia.",
"settings.notifications.statusReady": "settings.notifications.statusReady":
"Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.", "Możesz poprosić system o zgodę na powiadomienia. Android i desktop działają bez instalowania PWA, jeśli przeglądarka obsługuje Web Push.",
"settings.notifications.implementationNote": "settings.notifications.implementationNote": "",
"Serwerowy sprawdzacz wysyła nowe ostrzeżenia meteo IMGW oraz briefy przez Web Push. Subskrypcje i historia wysyłek są przechowywane w SQLite.",
"pwa.install": "Zainstaluj", "pwa.install": "Zainstaluj",
"common.noData": "Brak danych", "common.noData": "Brak danych",
"common.loading": "Ładowanie danych", "common.loading": "Ładowanie danych",
@@ -110,8 +135,7 @@ const translations = {
"error.description": "Sprawdź połączenie i spróbuj ponownie.", "error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.", "dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
"brief.label": "Brief dnia", "brief.label": "Brief dnia",
"brief.description": "brief.description": "Podsumowanie dla lokalizacji: {location}.",
"Automatyczne podsumowanie dla lokalizacji: {location}. Bez zewnętrznego modelu AI, wyłącznie z prognozy i ostrzeżeń.",
"brief.tomorrowLabel": "Jutro", "brief.tomorrowLabel": "Jutro",
"brief.pushHint": "brief.pushHint":
"Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.", "Krótkie wersje mogą być wysyłane jako powiadomienia o 7:00 i 18:00 po włączeniu ich w ustawieniach.",
@@ -128,17 +152,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",
@@ -195,8 +219,7 @@ const translations = {
"weather.temperatureDetail": "Temperatura powietrza", "weather.temperatureDetail": "Temperatura powietrza",
"forecast.label": "Prognoza modelowa", "forecast.label": "Prognoza modelowa",
"forecast.title": "Najbliższe godziny i dni", "forecast.title": "Najbliższe godziny i dni",
"forecast.description": "forecast.description": "Prognoza dla lokalizacji: {location}.",
"Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
"forecast.hourly": "Najbliższe 24 godziny", "forecast.hourly": "Najbliższe 24 godziny",
"forecast.daily": "Prognoza 7-dniowa", "forecast.daily": "Prognoza 7-dniowa",
"forecast.today": "Dzisiaj", "forecast.today": "Dzisiaj",
@@ -221,9 +244,8 @@ const translations = {
"forecast.maxProbability": "Maks. szansa opadu", "forecast.maxProbability": "Maks. szansa opadu",
"forecast.pastHour": "Miniona godzina", "forecast.pastHour": "Miniona godzina",
"forecast.source": "Źródło prognozy:", "forecast.source": "Źródło prognozy:",
"forecast.sourceCombinedDescription": "forecast.sourceCombinedDescription": "IMGW ALARO i Open-Meteo.",
"IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.", "forecast.sourceFallbackDescription": "Open-Meteo.",
"forecast.sourceFallbackDescription": "Prognoza jest wyświetlana jako modelowa prognoza Open-Meteo.",
"forecast.error": "Nie udało się pobrać prognozy modelowej.", "forecast.error": "Nie udało się pobrać prognozy modelowej.",
"forecast.emptyTitle": "Brak prognozy", "forecast.emptyTitle": "Brak prognozy",
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.", "forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
@@ -255,8 +277,7 @@ const translations = {
"Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.", "Wizualizacja bieżącego odczytu. API synoptyczne IMGW nie udostępnia historii ani prognozy godzinowej.",
"warnings.section": "Komunikaty IMGW", "warnings.section": "Komunikaty IMGW",
"warnings.title": "Ostrzeżenia", "warnings.title": "Ostrzeżenia",
"warnings.description": "warnings.description": "Aktualne ostrzeżenia meteorologiczne i hydrologiczne IMGW.",
"Aktualne ostrzeżenia meteorologiczne i hydrologiczne publikowane przez IMGW. Szczegóły obszaru i czasu obowiązywania pochodzą bezpośrednio z API.",
"warnings.myProvince": "Mój obszar", "warnings.myProvince": "Mój obszar",
"warnings.myProvinceDescription": "warnings.myProvinceDescription":
"Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.", "Najpierw pokazujemy komunikaty IMGW dotyczące obszaru {province}, zgodnie z lokalizacją wybraną w pogodzie.",
@@ -269,8 +290,7 @@ const translations = {
"warnings.emptyTitle": "Brak aktywnych ostrzeżeń", "warnings.emptyTitle": "Brak aktywnych ostrzeżeń",
"warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.", "warnings.emptyDescription": "IMGW nie publikuje obecnie ostrzeżeń meteorologicznych ani hydrologicznych.",
"warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski", "warnings.globalUnavailableTitle": "Oficjalne ostrzeżenia tylko dla Polski",
"warnings.globalUnavailableDescription": "warnings.globalUnavailableDescription": "Oficjalne ostrzeżenia są obecnie dostępne tylko dla Polski.",
"Oficjalne ostrzeżenia pogodowe są obecnie obsługiwane tylko dla Polski przez IMGW. Dla tej lokalizacji możesz korzystać z prognozy modelowej Open-Meteo, ale nie traktujemy jej jako oficjalnego alertu.",
"warnings.drought": "Susza hydrologiczna", "warnings.drought": "Susza hydrologiczna",
"warnings.levelUnknown": "Poziom nieokreślony", "warnings.levelUnknown": "Poziom nieokreślony",
"warnings.level": "Stopień {level}", "warnings.level": "Stopień {level}",
@@ -291,8 +311,7 @@ const translations = {
"warnings.dashboard.viewAll": "Zobacz wszystkie", "warnings.dashboard.viewAll": "Zobacz wszystkie",
"hydro.section": "Monitoring wód IMGW", "hydro.section": "Monitoring wód IMGW",
"hydro.title": "Hydro", "hydro.title": "Hydro",
"hydro.description": "hydro.description": "Najnowsze pomiary poziomu, temperatury i przepływu wody.",
"Najnowsze dostępne pomiary poziomu wody, temperatury i przepływu. Każdy parametr może mieć własny czas aktualizacji.",
"hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.", "hydro.error": "Nie udało się pobrać stacji hydrologicznych IMGW.",
"hydro.searchLabel": "Szukaj stacji hydrologicznej", "hydro.searchLabel": "Szukaj stacji hydrologicznej",
"hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…", "hydro.searchPlaceholder": "Szukaj stacji, rzeki lub województwa…",
@@ -308,6 +327,7 @@ const translations = {
"offline.description": "offline.description":
"wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.", "wtr. nie może teraz pobrać aktualnych danych IMGW. Ostatnio odwiedzone widoki mogą być dostępne z pamięci urządzenia.",
"offline.back": "Wróć do aplikacji", "offline.back": "Wróć do aplikacji",
"offline.banner": "Brak połączenia · dane mogą być nieaktualne",
}, },
en: { en: {
"nav.weather": "Weather", "nav.weather": "Weather",
@@ -324,18 +344,26 @@ const translations = {
"theme.dark": "Enable dark theme", "theme.dark": "Enable dark theme",
"settings.section": "Preferences", "settings.section": "Preferences",
"settings.title": "Settings", "settings.title": "Settings",
"settings.description": "settings.description": "Customize appearance, language, sections and notifications.",
"Manage language, appearance and preparation for IMGW meteorological warning notifications.",
"settings.language.title": "Language", "settings.language.title": "Language",
"settings.language.description": "settings.language.description":
"Change the interface language. Station names and IMGW content are not translated automatically.", "Change the interface language. Station names and IMGW content are not translated automatically.",
"settings.theme.title": "Theme", "settings.theme.title": "Theme",
"settings.theme.description": "Switch the light or dark appearance on this device.", "settings.theme.description": "Switch the light or dark appearance on this device.",
"settings.dashboardSections.title": "Home screen", "settings.dashboardSections.title": "Home screen",
"settings.dashboardSections.description": "Choose which additional sections are visible on the dashboard.", "settings.dashboardSections.description": "Choose visibility and order for sections below the main weather card.",
"settings.dashboardSections.moveUp": "Move {section} up",
"settings.dashboardSections.moveDown": "Move {section} down",
"settings.dashboardSections.warnings.title": "Warnings",
"settings.dashboardSections.warnings.description":
"Shows active and upcoming warnings for the selected location. On the home screen, this section appears only when an active weather warning exists.",
"settings.dashboardSections.weatherBrief.title": "Daily brief", "settings.dashboardSections.weatherBrief.title": "Daily brief",
"settings.dashboardSections.weatherBrief.description": "settings.dashboardSections.weatherBrief.description":
"Shows the deterministic daily summary and a short forecast for tomorrow.", "Shows the deterministic daily summary and a short forecast for tomorrow.",
"settings.dashboardSections.forecast.title": "7-day forecast",
"settings.dashboardSections.forecast.description": "Shows the next hours, weekly forecast and daily charts.",
"settings.dashboardSections.favorites.title": "Favorites",
"settings.dashboardSections.favorites.description": "Shows saved places and stations on the home screen.",
"settings.dashboardSections.featuredStations.title": "Quick select", "settings.dashboardSections.featuredStations.title": "Quick select",
"settings.dashboardSections.featuredStations.description": "settings.dashboardSections.featuredStations.description":
"Shows the popular locations list at the bottom of the home screen.", "Shows the popular locations list at the bottom of the home screen.",
@@ -357,6 +385,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.",
@@ -370,16 +419,12 @@ const translations = {
"Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.", "Official IMGW warnings are currently supported only for Poland. For this location you can use briefs based on the Open-Meteo model forecast.",
"settings.notifications.deviceTitle": "This device", "settings.notifications.deviceTitle": "This device",
"settings.notifications.enable": "IMGW alerts", "settings.notifications.enable": "IMGW alerts",
"settings.notifications.enableDescription": "settings.notifications.enableDescription": "Receive alerts for the selected area.",
"The subscription will be saved for the selected province and used by the server warning checker.", "settings.notifications.enableGlobalDescription": "Receive briefs for the selected location.",
"settings.notifications.enableGlobalDescription":
"The subscription will be saved for the selected location and used for model forecast briefs.",
"settings.notifications.morningBrief": "Morning brief at 7:00", "settings.notifications.morningBrief": "Morning brief at 7:00",
"settings.notifications.morningBriefDescription": "settings.notifications.morningBriefDescription": "A short weather summary for today.",
"Uses the selected location and model forecast to send a short daily summary to this device.",
"settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00", "settings.notifications.tomorrowBrief": "Tomorrow brief at 18:00",
"settings.notifications.tomorrowBriefDescription": "settings.notifications.tomorrowBriefDescription": "An evening forecast for the next day.",
"Sends a short evening forecast for the next day, including storm and rain signals from the model.",
"settings.notifications.enableDevice": "Enable on this device", "settings.notifications.enableDevice": "Enable on this device",
"settings.notifications.disable": "Disable on this device", "settings.notifications.disable": "Disable on this device",
"settings.notifications.enabledStatus": "Enabled", "settings.notifications.enabledStatus": "Enabled",
@@ -394,7 +439,7 @@ const translations = {
"settings.notifications.testError": "Unable to send the test notification.", "settings.notifications.testError": "Unable to send the test notification.",
"settings.notifications.requestPermission": "Check permission", "settings.notifications.requestPermission": "Check permission",
"settings.notifications.statusChecking": "Checking notification support in this browser.", "settings.notifications.statusChecking": "Checking notification support in this browser.",
"settings.notifications.statusUnconfigured": "The server does not have Web Push VAPID keys configured yet.", "settings.notifications.statusUnconfigured": "Notifications are unavailable.",
"settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.", "settings.notifications.statusUnsupported": "This browser does not provide Web Push for this app.",
"settings.notifications.statusNeedsInstall": "settings.notifications.statusNeedsInstall":
"On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.", "On iPhone, add the app to the Home Screen using Share → Add to Home Screen, then open it from the icon.",
@@ -402,8 +447,7 @@ const translations = {
"settings.notifications.statusGranted": "This device has notification permission.", "settings.notifications.statusGranted": "This device has notification permission.",
"settings.notifications.statusReady": "settings.notifications.statusReady":
"You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.", "You can ask the system for notification permission. Android and desktop work without installing the PWA when the browser supports Web Push.",
"settings.notifications.implementationNote": "settings.notifications.implementationNote": "",
"The server checker sends new IMGW meteorological warnings and briefs through Web Push. Subscriptions and delivery history are stored in SQLite.",
"pwa.install": "Install", "pwa.install": "Install",
"common.noData": "No data", "common.noData": "No data",
"common.loading": "Loading data", "common.loading": "Loading data",
@@ -412,7 +456,7 @@ const translations = {
"error.description": "Check your connection and try again.", "error.description": "Check your connection and try again.",
"dashboard.error": "Unable to load the IMGW synoptic station list.", "dashboard.error": "Unable to load the IMGW synoptic station list.",
"brief.label": "Daily brief", "brief.label": "Daily brief",
"brief.description": "Automatic summary for: {location}. No external AI model, only forecast data and warnings.", "brief.description": "Summary for: {location}.",
"brief.tomorrowLabel": "Tomorrow", "brief.tomorrowLabel": "Tomorrow",
"brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them in settings.", "brief.pushHint": "Short versions can be sent as 7:00 and 18:00 notifications after enabling them in settings.",
"brief.error": "Unable to prepare the daily brief.", "brief.error": "Unable to prepare the daily brief.",
@@ -428,7 +472,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.",
@@ -436,11 +480,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",
@@ -497,8 +541,7 @@ const translations = {
"weather.temperatureDetail": "Air temperature", "weather.temperatureDetail": "Air temperature",
"forecast.label": "Model forecast", "forecast.label": "Model forecast",
"forecast.title": "Upcoming hours and days", "forecast.title": "Upcoming hours and days",
"forecast.description": "forecast.description": "Forecast for: {location}.",
"Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
"forecast.hourly": "Next 24 hours", "forecast.hourly": "Next 24 hours",
"forecast.daily": "7-day forecast", "forecast.daily": "7-day forecast",
"forecast.today": "Today", "forecast.today": "Today",
@@ -523,9 +566,8 @@ const translations = {
"forecast.maxProbability": "Max. rain chance", "forecast.maxProbability": "Max. rain chance",
"forecast.pastHour": "Past hour", "forecast.pastHour": "Past hour",
"forecast.source": "Forecast source:", "forecast.source": "Forecast source:",
"forecast.sourceCombinedDescription": "forecast.sourceCombinedDescription": "IMGW ALARO and Open-Meteo.",
"IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.", "forecast.sourceFallbackDescription": "Open-Meteo.",
"forecast.sourceFallbackDescription": "The forecast is shown as an Open-Meteo model forecast.",
"forecast.error": "Unable to load the model forecast.", "forecast.error": "Unable to load the model forecast.",
"forecast.emptyTitle": "Forecast unavailable", "forecast.emptyTitle": "Forecast unavailable",
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.", "forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
@@ -557,8 +599,7 @@ const translations = {
"Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.", "Visualisation of the current reading. The IMGW synoptic API does not provide historical data or an hourly forecast.",
"warnings.section": "IMGW notices", "warnings.section": "IMGW notices",
"warnings.title": "Warnings", "warnings.title": "Warnings",
"warnings.description": "warnings.description": "Current IMGW meteorological and hydrological warnings.",
"Current meteorological and hydrological warnings published by IMGW. Area and validity details come directly from the API.",
"warnings.myProvince": "My area", "warnings.myProvince": "My area",
"warnings.myProvinceDescription": "warnings.myProvinceDescription":
"IMGW notices for {province} are shown first, based on the location selected in weather.", "IMGW notices for {province} are shown first, based on the location selected in weather.",
@@ -570,8 +611,7 @@ const translations = {
"warnings.emptyTitle": "No active warnings", "warnings.emptyTitle": "No active warnings",
"warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.", "warnings.emptyDescription": "IMGW is not currently publishing any meteorological or hydrological warnings.",
"warnings.globalUnavailableTitle": "Official warnings only for Poland", "warnings.globalUnavailableTitle": "Official warnings only for Poland",
"warnings.globalUnavailableDescription": "warnings.globalUnavailableDescription": "Official warnings are currently available only for Poland.",
"Official weather warnings are currently supported only for Poland through IMGW. For this location you can use the Open-Meteo model forecast, but it is not treated as an official alert.",
"warnings.drought": "Hydrological drought", "warnings.drought": "Hydrological drought",
"warnings.levelUnknown": "Level not specified", "warnings.levelUnknown": "Level not specified",
"warnings.level": "Level {level}", "warnings.level": "Level {level}",
@@ -592,8 +632,7 @@ const translations = {
"warnings.dashboard.viewAll": "View all", "warnings.dashboard.viewAll": "View all",
"hydro.section": "IMGW water monitoring", "hydro.section": "IMGW water monitoring",
"hydro.title": "Hydro", "hydro.title": "Hydro",
"hydro.description": "hydro.description": "Latest water level, temperature and flow readings.",
"Latest available water level, temperature and flow readings. Each parameter may have a different update time.",
"hydro.error": "Unable to load IMGW hydrological stations.", "hydro.error": "Unable to load IMGW hydrological stations.",
"hydro.searchLabel": "Search hydrological stations", "hydro.searchLabel": "Search hydrological stations",
"hydro.searchPlaceholder": "Search by station, river or province…", "hydro.searchPlaceholder": "Search by station, river or province…",
@@ -609,6 +648,7 @@ const translations = {
"offline.description": "offline.description":
"wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.", "wtr. cannot fetch current IMGW data right now. Recently visited views may still be available from your device cache.",
"offline.back": "Back to the app", "offline.back": "Back to the app",
"offline.banner": "Offline · data may be out of date",
}, },
} as const; } as const;

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,
@@ -12,12 +12,26 @@ import {
type AppSectionVisibility, type AppSectionVisibility,
} from "@/lib/app-sections"; } from "@/lib/app-sections";
import { import {
DEFAULT_DASHBOARD_SECTION_ORDER,
DEFAULT_DASHBOARD_SECTION_VISIBILITY, DEFAULT_DASHBOARD_SECTION_VISIBILITY,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility, normalizeDashboardSectionVisibility,
type DashboardSectionId, type DashboardSectionId,
type DashboardSectionOrder,
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,7 +48,11 @@ 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;
dashboardSectionOrder: DashboardSectionOrder;
appSections: AppSectionVisibility; appSections: AppSectionVisibility;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
selectStation: (id: string) => void; selectStation: (id: string) => void;
@@ -46,7 +64,11 @@ 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;
moveDashboardSection: (section: DashboardSectionId, direction: -1 | 1) => void;
setAppSectionVisible: (section: AppSectionId, visible: boolean) => void; setAppSectionVisible: (section: AppSectionId, visible: boolean) => void;
} }
@@ -63,7 +85,11 @@ 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,
dashboardSectionOrder: DEFAULT_DASHBOARD_SECTION_ORDER,
appSections: DEFAULT_APP_SECTION_VISIBILITY, appSections: DEFAULT_APP_SECTION_VISIBILITY,
toggleFavorite: (id) => toggleFavorite: (id) =>
set((state) => ({ set((state) => ({
@@ -80,10 +106,23 @@ 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 },
})), })),
moveDashboardSection: (section, direction) =>
set((state) => {
const order = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
const index = order.indexOf(section);
const nextIndex = index + direction;
if (index === -1 || nextIndex < 0 || nextIndex >= order.length) return state;
const nextOrder = [...order];
[nextOrder[index], nextOrder[nextIndex]] = [nextOrder[nextIndex], nextOrder[index]];
return { dashboardSectionOrder: nextOrder };
}),
setAppSectionVisible: (section, visible) => setAppSectionVisible: (section, visible) =>
set((state) => ({ set((state) => ({
appSections: { ...state.appSections, [section]: visible }, appSections: { ...state.appSections, [section]: visible },
@@ -99,13 +138,25 @@ export const useWeatherStore = create<WeatherStore>()(
| undefined; | undefined;
if (!state) return persisted; if (!state) return persisted;
const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections); const dashboardSections = normalizeDashboardSectionVisibility(state.dashboardSections);
const dashboardSectionOrder = normalizeDashboardSectionOrder(state.dashboardSectionOrder);
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, dashboardSectionOrder, 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,
dashboardSectionOrder,
appSections, appSections,
selectedLocation: { selectedLocation: {
...location, ...location,

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

@@ -1,6 +1,14 @@
const CACHE_NAME = "wtr-shell-v5"; const CACHE_VERSION = "v6";
const CACHE_PREFIX = "wtr-";
const SHELL_CACHE = `${CACHE_PREFIX}shell-${CACHE_VERSION}`;
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${CACHE_VERSION}`;
const API_CACHE = `${CACHE_PREFIX}api-${CACHE_VERSION}`;
const ACTIVE_CACHES = new Set([SHELL_CACHE, RUNTIME_CACHE, API_CACHE]);
const SHELL = [ const SHELL = [
"/", "/",
"/warnings",
"/hydro",
"/settings", "/settings",
"/offline", "/offline",
"/manifest.json", "/manifest.json",
@@ -11,8 +19,75 @@ const SHELL = [
"/icons/maskable-512.png", "/icons/maskable-512.png",
]; ];
function isCacheableResponse(response) {
return response && response.ok && response.type !== "opaque";
}
async function putInCache(cacheName, request, response) {
if (!isCacheableResponse(response)) return;
const cache = await caches.open(cacheName);
await cache.put(request, response.clone());
}
function isWeatherApiRequest(url) {
return (
url.pathname.startsWith("/api/imgw/") ||
url.pathname === "/api/imgw-current" ||
url.pathname === "/api/current-weather" ||
url.pathname === "/api/forecast"
);
}
function isStaticAssetRequest(request, url) {
return (
url.origin === self.location.origin &&
(url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname === "/manifest.json" ||
["font", "image", "script", "style", "worker"].includes(request.destination))
);
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
await putInCache(RUNTIME_CACHE, request, response);
return response;
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
await putInCache(cacheName, request, response);
return response;
} catch {
return (
(await caches.match(request)) ||
new Response(JSON.stringify({ error: "Offline." }), {
status: 503,
headers: { "Content-Type": "application/json" },
})
);
}
}
async function handleNavigation(request) {
try {
const response = await fetch(request);
await putInCache(SHELL_CACHE, request, response);
return response;
} catch {
return (await caches.match(request)) || (await caches.match("/")) || (await caches.match("/offline"));
}
}
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(new Request(url, { cache: "reload" }))))),
);
self.skipWaiting(); self.skipWaiting();
}); });
@@ -20,44 +95,42 @@ self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches caches
.keys() .keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), .then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && !ACTIVE_CACHES.has(key))
.map((key) => caches.delete(key)),
),
),
); );
self.clients.claim(); self.clients.claim();
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return; if (event.request.method !== "GET") return;
const url = new URL(event.request.url); const url = new URL(event.request.url);
if ( if (url.origin !== self.location.origin) return;
url.pathname.startsWith("/api/imgw/") ||
url.pathname === "/api/imgw-current" || if (isWeatherApiRequest(url)) {
url.pathname === "/api/current-weather" || event.respondWith(networkFirst(event.request, API_CACHE));
url.pathname === "/api/forecast"
) {
event.respondWith(
fetch(event.request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match(event.request)),
);
return; return;
} }
if (event.request.mode === "navigate") { if (event.request.mode === "navigate") {
event.respondWith( event.respondWith(handleNavigation(event.request));
fetch(event.request).catch(() => return;
caches.match(event.request).then((response) => response || caches.match("/offline")), }
),
); if (isStaticAssetRequest(event.request, url)) {
event.respondWith(cacheFirst(event.request));
} }
}); });
self.addEventListener("push", (event) => { self.addEventListener("push", (event) => {
const fallbackPayload = { const fallbackPayload = {
title: "IMGW", title: "IMGW",
body: "Nowe ostrzeżenie meteorologiczne.", body: "Nowe ostrze\u017cenie meteorologiczne.",
url: "/warnings", url: "/warnings",
}; };
let payload = fallbackPayload; let payload = fallbackPayload;

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_DASHBOARD_SECTION_ORDER,
normalizeDashboardSectionOrder,
normalizeDashboardSectionVisibility,
} from "@/lib/dashboard-sections";
describe("dashboard sections", () => {
it("keeps stored section order and appends missing defaults", () => {
expect(normalizeDashboardSectionOrder(["forecast", "weatherBrief", "unknown"])).toEqual([
"forecast",
"weatherBrief",
"warnings",
"favorites",
"featuredStations",
]);
});
it("falls back to the default order for invalid values", () => {
expect(normalizeDashboardSectionOrder(null)).toEqual(DEFAULT_DASHBOARD_SECTION_ORDER);
});
it("normalizes visibility for older stored preferences", () => {
expect(normalizeDashboardSectionVisibility({ weatherBrief: false })).toEqual({
warnings: true,
weatherBrief: false,
forecast: true,
favorites: true,
featuredStations: false,
});
});
});

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";