Compare commits
5 Commits
f15ebd28dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c2515abba | |||
| 6bec7060e0 | |||
| c7da9e0d94 | |||
| 91acdb39b8 | |||
| ab6b7b414f |
@@ -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ą.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 : "—"}%`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) }));
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
162
lib/i18n.tsx
162
lib/i18n.tsx
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
57
lib/store.ts
57
lib/store.ts
@@ -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,
|
||||||
|
|||||||
@@ -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}:`,
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
121
public/sw.js
121
public/sw.js
@@ -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;
|
||||||
|
|||||||
33
tests/dashboard-sections.test.ts
Normal file
33
tests/dashboard-sections.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user