feat: add configurable wind units

This commit is contained in:
zv
2026-06-13 13:36:36 +02:00
parent a8d4d1e23c
commit 7ad95550eb
21 changed files with 152 additions and 38 deletions

View File

@@ -4,6 +4,7 @@ import { fetchMeteoWarnings } from "@/lib/server-warnings";
import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } from "@/lib/push-store"; import { getPushSubscriptions, hasSentMorningBrief, markMorningBriefSent, removePushSubscription } 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_WIND_SPEED_UNIT } from "@/lib/weather-utils";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -66,6 +67,7 @@ export async function GET(request: Request) {
countyTeryt: subscription.countyTeryt, countyTeryt: subscription.countyTeryt,
locationName: subscription.locationName ?? "wtr.", locationName: subscription.locationName ?? "wtr.",
language: subscription.language, language: subscription.language,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
now, now,
}); });
if (!brief) { if (!brief) {

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { upsertPushSubscription, removePushSubscription } from "@/lib/push-store"; 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 { DEFAULT_WIND_SPEED_UNIT, isWindSpeedUnit } from "@/lib/weather-utils";
import type { Language } from "@/lib/i18n"; import type { Language } from "@/lib/i18n";
import type { BrowserPushSubscription } from "@/types/notifications"; import type { BrowserPushSubscription } from "@/types/notifications";
@@ -32,10 +33,12 @@ export async function POST(request: Request) {
longitude?: unknown; longitude?: unknown;
locationName?: unknown; locationName?: unknown;
countyTeryt?: unknown; countyTeryt?: unknown;
windSpeedUnit?: unknown;
} | null; } | null;
const subscription = body?.subscription; const subscription = body?.subscription;
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null); const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
const language: Language = body?.language === "en" ? "en" : "pl"; const language: Language = body?.language === "en" ? "en" : "pl";
const rawWindSpeedUnit = body?.windSpeedUnit;
if (!isBrowserPushSubscription(subscription) || !province) { if (!isBrowserPushSubscription(subscription) || !province) {
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 }); return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
@@ -54,6 +57,7 @@ export async function POST(request: Request) {
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null, longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null, locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
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,
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });

View File

@@ -4,6 +4,7 @@ import { fetchMeteoWarnings } from "@/lib/server-warnings";
import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } from "@/lib/push-store"; import { getPushSubscriptions, hasSentTomorrowBrief, markTomorrowBriefSent, removePushSubscription } 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_WIND_SPEED_UNIT } from "@/lib/weather-utils";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -76,6 +77,7 @@ export async function GET(request: Request) {
countyTeryt: subscription.countyTeryt, countyTeryt: subscription.countyTeryt,
locationName: subscription.locationName ?? "wtr.", locationName: subscription.locationName ?? "wtr.",
language: subscription.language, language: subscription.language,
windSpeedUnit: subscription.windSpeedUnit ?? DEFAULT_WIND_SPEED_UNIT,
now, now,
}); });
if (!brief) { if (!brief) {

View File

@@ -4,6 +4,8 @@ import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
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 { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = [ const SNAPSHOT_COLORS = [
@@ -14,9 +16,13 @@ const SNAPSHOT_COLORS = [
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 windDigits = windSpeedUnit === "ms" ? 1 : 0;
const windSpeed = station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45;
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] },
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: SNAPSHOT_COLORS[1] }, { name: t("weather.wind"), value: windSpeed, unit: getWindSpeedUnitLabel(windSpeedUnit), max: windMax, color: SNAPSHOT_COLORS[1] },
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] }, { name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) })); ].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));

View File

@@ -20,16 +20,17 @@ export function WeatherBriefCard({ latitude, longitude, locationName }: { latitu
const { data: warnings = [] } = useWarnings(); const { data: warnings = [] } = useWarnings();
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 windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID); const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const brief = useMemo(() => { const brief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit });
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]);
const tomorrowBrief = useMemo(() => { const tomorrowBrief = useMemo(() => {
if (!forecast) return null; if (!forecast) return null;
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language }); return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, windSpeedUnit });
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings]); }, [forecast, language, locationName, province, selectedLocation?.countyTeryt, warnings, windSpeedUnit]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) { if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
return <LoadingSkeleton className="h-48" />; return <LoadingSkeleton className="h-48" />;

View File

@@ -15,6 +15,7 @@ import {
formatForecastWind, formatForecastWind,
getForecastCondition, getForecastCondition,
} from "@/lib/forecast-utils"; } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store";
import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast"; import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast";
function formatHour(value: string | null) { function formatHour(value: string | null) {
@@ -53,6 +54,7 @@ export function DayForecastModal({
onClose: () => void; onClose: () => void;
}) { }) {
const { language, locale, t } = useI18n(); const { language, locale, t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
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]);
@@ -135,7 +137,7 @@ export function DayForecastModal({
</div> </div>
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]"> <div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
<DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} /> <DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} />
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} /> <DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} /> <DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} /> <DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
</div> </div>

View File

@@ -21,6 +21,7 @@ import {
getHourlyForecastForDay, getHourlyForecastForDay,
getUpcomingHourlyForecast, getUpcomingHourlyForecast,
} from "@/lib/forecast-utils"; } from "@/lib/forecast-utils";
import { useWeatherStore } from "@/lib/store";
import type { DailyForecast, HourlyForecast } from "@/types/forecast"; import type { DailyForecast, HourlyForecast } from "@/types/forecast";
function formatHour(value: string) { function formatHour(value: string) {
@@ -59,6 +60,7 @@ function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; l
function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) { function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
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));
@@ -73,7 +75,7 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p>
<div className="mt-3 grid grid-cols-4 gap-2"> <div className="mt-3 grid grid-cols-4 gap-2">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} /> <HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} /> <HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
<HourlySummaryMetric icon={CloudRain} label={t("forecast.rainfallTotal")} value={formatForecastRainfall(rainfallTotal, language)} /> <HourlySummaryMetric icon={CloudRain} label={t("forecast.rainfallTotal")} value={formatForecastRainfall(rainfallTotal, language)} />
<HourlySummaryMetric icon={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} /> <HourlySummaryMetric icon={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} />
</div> </div>
@@ -113,6 +115,7 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null); const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
const closeDayDetails = useCallback(() => setSelectedDay(null), []); const closeDayDetails = useCallback(() => setSelectedDay(null), []);
@@ -167,7 +170,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
</p> </p>
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}> <p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
<Wind className="size-3 text-accent" /> <Wind className="size-3 text-accent" />
{formatForecastWind(hour.windSpeed, language)} {formatForecastWind(hour.windSpeed, language, windSpeedUnit)}
</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" />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone } from "lucide-react"; import { Bell, BellRing, Clock3, Languages, MapPinned, Palette, Settings, Smartphone, Wind } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -15,7 +15,9 @@ import { locateSynopStations } from "@/lib/location-utils";
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } from "@/lib/notification-api"; import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription, sendTestPushNotification } 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 { WIND_SPEED_UNITS } from "@/lib/weather-utils";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import type { WindSpeedUnit } from "@/types/units";
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted"; type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
@@ -100,6 +102,12 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa
); );
} }
const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"> = {
ms: "settings.units.wind.ms",
kmh: "settings.units.wind.kmh",
mph: "settings.units.wind.mph",
};
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");
@@ -116,11 +124,13 @@ export function SettingsPage() {
const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled); const tomorrowBriefEnabled = useWeatherStore((state) => state.tomorrowBriefNotificationsEnabled);
const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode); const provinceMode = useWeatherStore((state) => state.warningNotificationProvinceMode);
const manualProvince = useWeatherStore((state) => state.warningNotificationProvince); const manualProvince = useWeatherStore((state) => state.warningNotificationProvince);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled); const setNotificationsEnabled = useWeatherStore((state) => state.setWarningNotificationsEnabled);
const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled); const setMorningBriefEnabled = useWeatherStore((state) => state.setMorningBriefNotificationsEnabled);
const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled); const setTomorrowBriefEnabled = useWeatherStore((state) => state.setTomorrowBriefNotificationsEnabled);
const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode); const setProvinceMode = useWeatherStore((state) => state.setWarningNotificationProvinceMode);
const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince); const setManualProvince = useWeatherStore((state) => state.setWarningNotificationProvince);
const setWindSpeedUnit = useWeatherStore((state) => state.setWindSpeedUnit);
const selectedStation = stations?.find((station) => station.id === selectedStationId) const selectedStation = stations?.find((station) => station.id === selectedStationId)
?? stations?.find((station) => station.id === DEFAULT_STATION_ID) ?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
@@ -170,6 +180,7 @@ export function SettingsPage() {
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
windSpeedUnit,
}).catch(() => undefined); }).catch(() => undefined);
} }
@@ -177,7 +188,7 @@ export function SettingsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey]); }, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
const notificationStatusLabel = useMemo(() => { const notificationStatusLabel = useMemo(() => {
switch (notificationStatus) { switch (notificationStatus) {
@@ -236,6 +247,7 @@ export function SettingsPage() {
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
windSpeedUnit,
}); });
setNotificationsEnabled(true); setNotificationsEnabled(true);
setNotificationMessage(t("settings.notifications.saveSuccess")); setNotificationMessage(t("settings.notifications.saveSuccess"));
@@ -298,6 +310,7 @@ export function SettingsPage() {
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
windSpeedUnit,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -318,6 +331,7 @@ export function SettingsPage() {
longitude: notificationLongitude, longitude: notificationLongitude,
locationName: notificationLocationName, locationName: notificationLocationName,
countyTeryt: notificationCountyTeryt, countyTeryt: notificationCountyTeryt,
windSpeedUnit,
}); });
} catch { } catch {
setNotificationMessage(t("settings.notifications.saveError")); setNotificationMessage(t("settings.notifications.saveError"));
@@ -339,6 +353,26 @@ export function SettingsPage() {
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}> <SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
<ThemeToggle /> <ThemeToggle />
</SettingsRow> </SettingsRow>
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
<div className="surface-control inline-flex rounded-control p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
{WIND_SPEED_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={windSpeedUnit === unit}
className={`rounded-[calc(var(--radius-control)-0.25rem)] px-3 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 ${
windSpeedUnit === unit
? "bg-accent text-accent-foreground shadow-soft"
: "text-muted hover:text-foreground"
}`}
onClick={() => setWindSpeedUnit(unit)}
>
{t(windSpeedUnitKeys[unit])}
</button>
))}
</div>
</SettingsRow>
</Card> </Card>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">

View File

@@ -5,15 +5,17 @@ import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, for
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import { MetricCard } from "@/components/weather/metric-card"; import { MetricCard } from "@/components/weather/metric-card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
export function CurrentConditionsCard({ station }: { station: SynopStation }) { export function CurrentConditionsCard({ station }: { station: SynopStation }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed); const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
const metrics = [ const metrics = [
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language), detail: t("weather.feelsLikeDetail") }, { icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language), detail: t("weather.feelsLikeDetail") },
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(station.humidity, language), detail: t("weather.humidityDetail") },
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(station.pressure, language), detail: t("weather.pressureDetail") },
{ icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language), detail: t("weather.windSpeedDetail") }, { icon: Wind, label: t("weather.windSpeed"), value: formatWind(station.windSpeed, null, language, windSpeedUnit), detail: t("weather.windSpeedDetail") },
{ icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") }, { icon: Navigation, label: t("weather.windDirection"), value: station.windDirection === null ? t("common.noData") : `${station.windDirection}° ${getWindDirection(station.windDirection)}`, detail: t("weather.windDirectionDetail") },
{ icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") }, { icon: Umbrella, label: t("weather.rainfallTotal"), value: formatRainfall(station.rainfall, language), detail: t("weather.rainfallDetail") },
{ icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language), detail: t("weather.temperatureDetail") }, { icon: CloudSun, label: t("weather.airTemperature"), value: formatTemperature(station.temperature, language), detail: t("weather.temperatureDetail") },

View File

@@ -4,7 +4,7 @@ import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Droplets, Gauge, Heart, Wind } from "lucide-react"; import { Droplets, Gauge, Heart, Wind } from "lucide-react";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { formatHumidity, formatPressure, formatTemperature, getWeatherMoodFromData } from "@/lib/weather-utils"; import { formatHumidity, formatPressure, formatTemperature, formatWind, getWeatherMoodFromData } from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw"; import type { SynopStation } from "@/types/imgw";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -17,9 +17,10 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const favorites = useWeatherStore((state) => state.favorites); const favorites = useWeatherStore((state) => state.favorites);
const toggleFavorite = useWeatherStore((state) => state.toggleFavorite); const toggleFavorite = useWeatherStore((state) => state.toggleFavorite);
const selectStation = useWeatherStore((state) => state.selectStation); const selectStation = useWeatherStore((state) => state.selectStation);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
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 ? "—" : `${station.windSpeed.toFixed(1)} m/s`; const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
return ( return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}> <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}>
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90"> <Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">

View File

@@ -19,6 +19,7 @@ import type { ImgwCurrentWeather } from "@/types/imgw-current";
import { WeatherIcon } from "@/components/weather/weather-icon"; import { WeatherIcon } from "@/components/weather/weather-icon";
import { WeatherEffects } from "@/components/weather/weather-effects"; import { WeatherEffects } from "@/components/weather/weather-effects";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useWeatherStore } from "@/lib/store";
type DevWeatherEffectOverride = "rain" | "thunderstorm" | "none"; type DevWeatherEffectOverride = "rain" | "thunderstorm" | "none";
@@ -43,6 +44,7 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) { export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
const { language, t } = useI18n(); const { language, t } = useI18n();
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
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 hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly"; const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
@@ -63,7 +65,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed); const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const metrics = [ const metrics = [
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) }, { icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, language) },
{ icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language) }, { icon: Wind, label: t("weather.wind"), value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit) },
{ icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) }, { icon: Umbrella, label: currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
{ icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) }, { icon: Gauge, label: t("weather.pressure"), value: formatPressure(displayedStation.pressure, language) },
]; ];

View File

@@ -42,7 +42,8 @@ Subskrypcja Web Push zapisuje:
- czy aktywny jest brief poranny, - czy aktywny jest brief poranny,
- 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ę wiatru dla briefów wysyłanych z serwera.
Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki. Ostrzeżenia meteo są filtrowane po powiecie TERYT, jeśli lokalizacja go dostarcza. W przeciwnym razie używany jest fallback wojewódzki.

View File

@@ -79,6 +79,8 @@ Prognoza godzinowa i dzienna rozpoznaje:
Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Gdy żadne z tych zjawisk nie występuje, hero może pokazać pomocniczy opis: `Silny wiatr`, `Wilgotne warunki` albo `Spokojne warunki`. Bieżąca analiza IMGW Hybrid rozpoznaje bezpośrednio opad deszczu, śnieg i burzę. Gdy żadne z tych zjawisk nie występuje, hero może pokazać pomocniczy opis: `Silny wiatr`, `Wilgotne warunki` albo `Spokojne warunki`.
Jednostka wiatru wybierana w `/settings` dotyczy prezentacji w UI, briefach i powiadomieniach. Dane źródłowe oraz progi logiki pogody pozostają liczone w `m/s`.
## Mood i Efekty Wizualne ## Mood i Efekty Wizualne
Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu. Hero aktualnej pogody używa uproszczonego moodu do wyboru ikony, tekstu i małego akcentu stanu.

View File

@@ -1,6 +1,8 @@
import type { Language, TranslationKey } from "@/lib/i18n"; import type { Language, TranslationKey } from "@/lib/i18n";
import { translate } from "@/lib/i18n"; import { translate } from "@/lib/i18n";
import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils";
import type { HourlyForecast } from "@/types/forecast"; import type { HourlyForecast } from "@/types/forecast";
import type { 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";
@@ -28,12 +30,9 @@ export function formatForecastRainfall(value: number | null, language: Language)
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
} }
export function formatForecastWind(value: number | null, language: Language) { export function formatForecastWind(value: number | null, language: Language, unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
if (value === null) return "—"; if (value === null) return "—";
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { return formatWindSpeed(value, language, unit);
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value)} m/s`;
} }
function getWarsawForecastHour(date = new Date()) { function getWarsawForecastHour(date = new Date()) {

View File

@@ -26,6 +26,12 @@ const translations = {
"settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.", "settings.language.description": "Zmieniaj język interfejsu. Nazwy stacji i treści IMGW pozostają bez automatycznego tłumaczenia.",
"settings.theme.title": "Motyw", "settings.theme.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.units.wind.title": "Jednostka wiatru",
"settings.units.wind.description": "Stosowana w prognozie, briefach, powiadomieniach i bieżących pomiarach na tym urządzeniu.",
"settings.units.wind.label": "Wybierz jednostkę wiatru",
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
"settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo", "settings.notifications.title": "Powiadomienia o ostrzeżeniach meteo",
"settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.", "settings.notifications.description": "Przygotuj alerty dla nowych ostrzeżeń meteorologicznych IMGW, takich jak burze, upał, silny wiatr lub intensywny deszcz.",
"settings.notifications.regionTitle": "Obszar alertów", "settings.notifications.regionTitle": "Obszar alertów",
@@ -262,6 +268,12 @@ const translations = {
"settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.", "settings.language.description": "Change the interface language. Station names and IMGW content are not translated automatically.",
"settings.theme.title": "Theme", "settings.theme.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.units.wind.title": "Wind unit",
"settings.units.wind.description": "Used in forecasts, briefs, notifications and current readings on this device.",
"settings.units.wind.label": "Choose wind unit",
"settings.units.wind.ms": "m/s",
"settings.units.wind.kmh": "km/h",
"settings.units.wind.mph": "mph",
"settings.notifications.title": "Weather warning notifications", "settings.notifications.title": "Weather warning notifications",
"settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.", "settings.notifications.description": "Prepare alerts for new IMGW meteorological warnings, such as thunderstorms, heat, strong wind or heavy rain.",
"settings.notifications.regionTitle": "Alert area", "settings.notifications.regionTitle": "Alert area",

View File

@@ -1,5 +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 { WindSpeedUnit } from "@/types/units";
interface VapidKeyResponse { interface VapidKeyResponse {
configured: boolean; configured: boolean;
@@ -19,6 +20,7 @@ interface SavePushSubscriptionOptions {
longitude?: number | null; longitude?: number | null;
locationName?: string | null; locationName?: string | null;
countyTeryt?: string | null; countyTeryt?: string | null;
windSpeedUnit?: WindSpeedUnit;
} }
export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) { export async function savePushSubscription(subscription: PushSubscription, province: Province, language: Language, options: SavePushSubscriptionOptions = {}) {
@@ -36,6 +38,7 @@ export async function savePushSubscription(subscription: PushSubscription, provi
longitude: options.longitude ?? null, longitude: options.longitude ?? null,
locationName: options.locationName ?? null, locationName: options.locationName ?? null,
countyTeryt: options.countyTeryt ?? null, countyTeryt: options.countyTeryt ?? null,
windSpeedUnit: options.windSpeedUnit,
}), }),
}); });
if (!response.ok) throw new Error("Unable to save push subscription."); if (!response.ok) throw new Error("Unable to save push subscription.");

View File

@@ -4,6 +4,8 @@ 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 { WindSpeedUnit } from "@/types/units";
import { DEFAULT_WIND_SPEED_UNIT } from "@/lib/weather-utils";
type NotificationProvinceMode = "selected" | "manual"; type NotificationProvinceMode = "selected" | "manual";
@@ -16,6 +18,7 @@ interface WeatherStore {
tomorrowBriefNotificationsEnabled: boolean; tomorrowBriefNotificationsEnabled: boolean;
warningNotificationProvinceMode: NotificationProvinceMode; warningNotificationProvinceMode: NotificationProvinceMode;
warningNotificationProvince: Province | null; warningNotificationProvince: Province | null;
windSpeedUnit: WindSpeedUnit;
toggleFavorite: (id: string) => void; toggleFavorite: (id: string) => void;
selectStation: (id: string) => void; selectStation: (id: string) => void;
selectLocation: (location: SelectedLocation) => void; selectLocation: (location: SelectedLocation) => void;
@@ -24,6 +27,7 @@ interface WeatherStore {
setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void; setTomorrowBriefNotificationsEnabled: (enabled: boolean) => void;
setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void; setWarningNotificationProvinceMode: (mode: NotificationProvinceMode) => void;
setWarningNotificationProvince: (province: Province | null) => void; setWarningNotificationProvince: (province: Province | null) => void;
setWindSpeedUnit: (unit: WindSpeedUnit) => void;
} }
export const useWeatherStore = create<WeatherStore>()( export const useWeatherStore = create<WeatherStore>()(
@@ -37,6 +41,7 @@ export const useWeatherStore = create<WeatherStore>()(
tomorrowBriefNotificationsEnabled: false, tomorrowBriefNotificationsEnabled: false,
warningNotificationProvinceMode: "selected", warningNotificationProvinceMode: "selected",
warningNotificationProvince: null, warningNotificationProvince: null,
windSpeedUnit: DEFAULT_WIND_SPEED_UNIT,
toggleFavorite: (id) => toggleFavorite: (id) =>
set((state) => ({ set((state) => ({
favorites: state.favorites.includes(id) favorites: state.favorites.includes(id)
@@ -50,6 +55,7 @@ export const useWeatherStore = create<WeatherStore>()(
setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }), setTomorrowBriefNotificationsEnabled: (enabled) => set({ tomorrowBriefNotificationsEnabled: enabled }),
setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }), setWarningNotificationProvinceMode: (mode) => set({ warningNotificationProvinceMode: mode }),
setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }), setWarningNotificationProvince: (province) => set({ warningNotificationProvince: province }),
setWindSpeedUnit: (unit) => set({ windSpeedUnit: unit }),
}), }),
{ name: "wtr:preferences" }, { name: "wtr:preferences" },
), ),

View File

@@ -5,6 +5,8 @@ import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
import type { WeatherWarning } from "@/types/imgw"; import type { WeatherWarning } from "@/types/imgw";
import type { Province } from "@/types/province"; import type { Province } from "@/types/province";
import { warningMatchesCounty } from "@/lib/warning-regions"; import { warningMatchesCounty } from "@/lib/warning-regions";
import { DEFAULT_WIND_SPEED_UNIT, formatWindSpeed } from "@/lib/weather-utils";
import type { WindSpeedUnit } from "@/types/units";
export type WeatherBriefSeverity = "normal" | "attention" | "warning"; export type WeatherBriefSeverity = "normal" | "attention" | "warning";
@@ -24,6 +26,7 @@ interface BuildWeatherBriefInput {
countyTeryt?: string | null; countyTeryt?: string | null;
locationName: string; locationName: string;
language: Language; language: Language;
windSpeedUnit?: WindSpeedUnit;
now?: Date; now?: Date;
} }
@@ -37,14 +40,6 @@ function formatTemperature(value: number, language: Language) {
return `${formatNumber(value, language)}°C`; return `${formatNumber(value, language)}°C`;
} }
function formatWind(value: number, language: Language) {
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
}
function formatWindKmh(value: number, language: Language) {
return `${formatNumber(value * 3.6, language)} km/h`;
}
function formatRainfall(value: number, language: Language) { function formatRainfall(value: number, language: Language) {
return `${formatNumber(value, language, 1)} mm`; return `${formatNumber(value, language, 1)} mm`;
} }
@@ -207,7 +202,7 @@ function getTomorrowCondition(hours: HourlyForecast[], rainfallTotal: number | n
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation"; return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
} }
export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const hours = getUpcomingHours(forecast.hourly, now, 24); const hours = getUpcomingHours(forecast.hourly, now, 24);
if (!hours.length) return null; if (!hours.length) return null;
@@ -271,8 +266,8 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
} }
if (maximumWind !== null) { if (maximumWind !== null) {
body.push(language === "pl" body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWind(maximumWind, language)}.` ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWind(maximumWind, language)}.`); : `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
} }
if (conditionPeakHour) { if (conditionPeakHour) {
body.push(language === "pl" body.push(language === "pl"
@@ -284,7 +279,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
`${locationName}:`, `${locationName}:`,
temperatureRange, temperatureRange,
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null, maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWind(maximumWind, language)}` : `wind ${formatWind(maximumWind, language)}`) : null, maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
].filter(Boolean); ].filter(Boolean);
const warningPrefix = topWarning const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. ` ? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
@@ -300,7 +295,7 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
}; };
} }
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null { export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, windSpeedUnit = DEFAULT_WIND_SPEED_UNIT, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1); const targetDateKey = getWarsawDateKey(now, 1);
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey); const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
if (!hours.length) return null; if (!hours.length) return null;
@@ -378,8 +373,8 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
} }
if (maximumWind !== null) { if (maximumWind !== null) {
body.push(language === "pl" body.push(language === "pl"
? `Wiatr maksymalnie do ${formatWindKmh(maximumWind, language)}.` ? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindKmh(maximumWind, language)}.`); : `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`);
} }
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
@@ -390,7 +385,7 @@ export function buildTomorrowWeatherBrief({ forecast, warnings, province, county
temperatureRange, temperatureRange,
condition, condition,
rainPushPart, rainPushPart,
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(maximumWind, language)}`) : null, maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}` : `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`) : null,
].filter(Boolean); ].filter(Boolean);
const warningPrefix = topWarning const warningPrefix = topWarning
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. ` ? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `

View File

@@ -11,8 +11,17 @@ 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 { 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_WIND_SPEED_UNIT: WindSpeedUnit = "ms";
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph"];
const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
ms: "m/s",
kmh: "km/h",
mph: "mph",
};
export function toNumber(value: unknown): number | null { export function toNumber(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "number") return Number.isFinite(value) ? value : null;
@@ -108,10 +117,35 @@ 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)}%`;
} }
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl") { export function isWindSpeedUnit(value: unknown): value is WindSpeedUnit {
return typeof value === "string" && WIND_SPEED_UNITS.includes(value as WindSpeedUnit);
}
export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
return windSpeedUnitLabels[unit];
}
export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
if (unit === "kmh") return speed * 3.6;
if (unit === "mph") return speed * 2.2369362921;
return speed;
}
export function formatWindSpeed(speed: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
if (speed === null) return translate(language, "common.noData");
const convertedSpeed = convertWindSpeed(speed, unit);
const digits = unit === "ms" ? 1 : 0;
const formattedSpeed = new Intl.NumberFormat(locales[language], {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(convertedSpeed);
return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`;
}
export function formatWind(speed: number | null, direction?: number | null, language: Language = "pl", unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT) {
if (speed === null) return translate(language, "common.noData"); if (speed === null) return translate(language, "common.noData");
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`; const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
return `${speed.toFixed(1)} m/s${directionLabel}`; return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
} }
export function formatRainfall(value: number | null, language: Language = "pl") { export function formatRainfall(value: number | null, language: Language = "pl") {

View File

@@ -1,5 +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 { WindSpeedUnit } from "@/types/units";
export interface PushSubscriptionKeys { export interface PushSubscriptionKeys {
p256dh: string; p256dh: string;
@@ -24,6 +25,7 @@ export interface WarningPushSubscription {
longitude: number | null; longitude: number | null;
locationName: string | null; locationName: string | null;
countyTeryt: string | null; countyTeryt: string | null;
windSpeedUnit: WindSpeedUnit;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

1
types/units.ts Normal file
View File

@@ -0,0 +1 @@
export type WindSpeedUnit = "ms" | "kmh" | "mph";