feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
This commit is contained in:
@@ -18,6 +18,7 @@ import { getUpcomingHourlyForecast } from "@/lib/forecast-utils";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
import { DashboardWarnings } from "@/components/warnings/dashboard-warnings";
|
||||
import { WeatherBriefCard } from "@/components/dashboard/weather-brief-card";
|
||||
import type { SynopStation } from "@/types/imgw";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -28,7 +29,8 @@ export function DashboardPage() {
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||
?? stations?.[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
||||
const activeLocation = selectedLocation;
|
||||
const activeRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
@@ -36,20 +38,32 @@ export function DashboardPage() {
|
||||
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation?.name : selectedStation?.name;
|
||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude);
|
||||
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude);
|
||||
const { data: currentWeather, isPending: isCurrentWeatherPending } = useCurrentWeather(forecastLatitude, forecastLongitude, activeRegion);
|
||||
const { data: forecast } = useForecast(forecastLatitude, forecastLongitude, activeRegion);
|
||||
const currentForecastWeatherCode = forecast ? getUpcomingHourlyForecast(forecast.hourly, 1)[0]?.weatherCode ?? null : null;
|
||||
const isCurrentWeatherLoading = Number.isFinite(forecastLatitude) && Number.isFinite(forecastLongitude) && isCurrentWeatherPending;
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError || !stations?.length || !selectedStation) return <ErrorState onRetry={() => refetch()} description={t("dashboard.error")} />;
|
||||
|
||||
const heroStation: SynopStation = activeLocation?.region === "GLOBAL" ? {
|
||||
id: `global:${activeLocation.latitude},${activeLocation.longitude}`,
|
||||
name: activeLocation.name,
|
||||
measuredAt: null,
|
||||
temperature: null,
|
||||
windSpeed: null,
|
||||
windDirection: null,
|
||||
humidity: null,
|
||||
rainfall: null,
|
||||
pressure: null,
|
||||
} : selectedStation;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
<WeatherHero station={selectedStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<WeatherHero station={heroStation} currentWeather={currentWeather} currentWeatherLoading={isCurrentWeatherLoading} currentForecastWeatherCode={currentForecastWeatherCode} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<DashboardWarnings />
|
||||
<WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName ?? selectedStation.name} />
|
||||
<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} />
|
||||
<FeaturedStationsSection stations={stations} />
|
||||
</div>
|
||||
|
||||
@@ -13,25 +13,28 @@ import { useI18n } from "@/lib/i18n";
|
||||
import { getProvinceForSelection } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export function WeatherBriefCard({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
||||
export function WeatherBriefCard({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) {
|
||||
const { language, t } = useI18n();
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
|
||||
const { data: warnings = [] } = useWarnings();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const isGlobalLocation = region === "GLOBAL";
|
||||
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const relevantWarnings = useMemo(() => isGlobalLocation ? [] : warnings, [isGlobalLocation, warnings]);
|
||||
|
||||
const brief = useMemo(() => {
|
||||
if (!forecast) return null;
|
||||
return buildWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
||||
return buildWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
|
||||
const tomorrowBrief = useMemo(() => {
|
||||
if (!forecast) return null;
|
||||
return buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt: selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, language, locationName, province, selectedLocation?.countyTeryt, temperatureUnit, warnings, windSpeedUnit]);
|
||||
return buildTomorrowWeatherBrief({ forecast, warnings: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
|
||||
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, windSpeedUnit]);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending) {
|
||||
return <LoadingSkeleton className="h-48" />;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/forecast-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
function formatHour(value: string) {
|
||||
return value.slice(11, 16);
|
||||
@@ -118,11 +119,11 @@ 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, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; locationName: string }) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
|
||||
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
||||
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
|
||||
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
|
||||
|
||||
@@ -142,17 +142,22 @@ export function SettingsPage() {
|
||||
const selectedStation = stations?.find((station) => station.id === selectedStationId)
|
||||
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
|
||||
?? stations?.[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation?.id ? selectedLocation : null;
|
||||
const activeLocation = selectedLocation;
|
||||
const selectedRegion = activeLocation?.region ?? "PL";
|
||||
const stationPosition = selectedStation
|
||||
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
|
||||
: null;
|
||||
const notificationLatitude = Number.isFinite(activeLocation?.latitude) ? activeLocation?.latitude : stationPosition?.latitude ?? null;
|
||||
const notificationLongitude = Number.isFinite(activeLocation?.longitude) ? activeLocation?.longitude : stationPosition?.longitude ?? null;
|
||||
const notificationLocationName = activeLocation?.name ?? selectedStation?.name ?? null;
|
||||
const notificationCountyTeryt = activeLocation?.countyTeryt ?? null;
|
||||
const selectedProvince = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const effectiveProvince = provinceMode === "manual" ? manualProvince : selectedProvince;
|
||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
const notificationTimezone = activeLocation?.timezone ?? (selectedRegion === "PL" ? "Europe/Warsaw" : null);
|
||||
const notificationCountyTeryt = selectedRegion === "PL" ? activeLocation?.countyTeryt ?? null : null;
|
||||
const selectedProvince = selectedRegion === "PL" ? getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID) : null;
|
||||
const effectiveProvince = selectedRegion === "PL" ? provinceMode === "manual" ? manualProvince : selectedProvince : null;
|
||||
const canUseOfficialWarnings = selectedRegion === "PL" && Boolean(effectiveProvince);
|
||||
const effectiveProvinceLabel = selectedRegion === "GLOBAL"
|
||||
? t("settings.notifications.globalRegion")
|
||||
: effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,7 +178,7 @@ export function SettingsPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
if (!notificationsEnabled || !vapidPublicKey || notificationStatus !== "granted") return;
|
||||
const province = effectiveProvince;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -182,11 +187,14 @@ export function SettingsPage() {
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription || cancelled) return;
|
||||
await savePushSubscription(subscription, province, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||
morningBriefEnabled,
|
||||
tomorrowBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
@@ -197,7 +205,7 @@ export function SettingsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
||||
}, [canUseOfficialWarnings, effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, notificationTimezone, selectedRegion, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provinceDropdownOpen) return;
|
||||
@@ -229,7 +237,7 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [notificationStatus, t]);
|
||||
|
||||
const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
|
||||
const canUsePush = Boolean(vapidPublicKey && (selectedRegion === "GLOBAL" || effectiveProvince) && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
|
||||
|
||||
async function getServiceWorkerRegistration() {
|
||||
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
||||
@@ -237,7 +245,7 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
async function enableWarningNotifications() {
|
||||
if (!vapidPublicKey || !effectiveProvince) {
|
||||
if (!vapidPublicKey || (selectedRegion === "PL" && !effectiveProvince)) {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
return;
|
||||
}
|
||||
@@ -261,11 +269,14 @@ export function SettingsPage() {
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings,
|
||||
morningBriefEnabled,
|
||||
tomorrowBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
@@ -319,17 +330,20 @@ export function SettingsPage() {
|
||||
|
||||
async function updateMorningBriefPreference(enabled: boolean) {
|
||||
setMorningBriefEnabled(enabled);
|
||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||
morningBriefEnabled: enabled,
|
||||
tomorrowBriefEnabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
@@ -341,17 +355,20 @@ export function SettingsPage() {
|
||||
|
||||
async function updateTomorrowBriefPreference(enabled: boolean) {
|
||||
setTomorrowBriefEnabled(enabled);
|
||||
if (!notificationsEnabled || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
if (!notificationsEnabled || !canUsePush || notificationStatus !== "granted") return;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
await savePushSubscription(subscription, effectiveProvince, language, {
|
||||
region: selectedRegion,
|
||||
officialWarningsEnabled: canUseOfficialWarnings && notificationsEnabled,
|
||||
morningBriefEnabled,
|
||||
tomorrowBriefEnabled: enabled,
|
||||
latitude: notificationLatitude,
|
||||
longitude: notificationLongitude,
|
||||
locationName: notificationLocationName,
|
||||
timezone: notificationTimezone,
|
||||
countyTeryt: notificationCountyTeryt,
|
||||
temperatureUnit,
|
||||
windSpeedUnit,
|
||||
@@ -446,6 +463,7 @@ export function SettingsPage() {
|
||||
type="radio"
|
||||
name="warning-notification-province-mode"
|
||||
checked={provinceMode === "selected"}
|
||||
disabled={selectedRegion === "GLOBAL"}
|
||||
onChange={() => setProvinceMode("selected")}
|
||||
className="mt-1 accent-accent"
|
||||
/>
|
||||
@@ -459,6 +477,7 @@ export function SettingsPage() {
|
||||
type="radio"
|
||||
name="warning-notification-province-mode"
|
||||
checked={provinceMode === "manual"}
|
||||
disabled={selectedRegion === "GLOBAL"}
|
||||
onChange={() => {
|
||||
setProvinceMode("manual");
|
||||
if (!manualProvince) setManualProvince(selectedProvince ?? "mazowieckie");
|
||||
@@ -473,7 +492,7 @@ export function SettingsPage() {
|
||||
aria-label={t("settings.notifications.regionManual")}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={provinceDropdownOpen}
|
||||
disabled={provinceMode !== "manual"}
|
||||
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false);
|
||||
}}
|
||||
@@ -514,6 +533,11 @@ export function SettingsPage() {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{selectedRegion === "GLOBAL" && (
|
||||
<p className="rounded-card border border-border/60 bg-surface px-3 py-3 text-xs leading-5 text-muted">
|
||||
{t("settings.notifications.globalAlertsUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -541,9 +565,9 @@ export function SettingsPage() {
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<Bell className="size-3.5 text-accent" aria-hidden="true" />
|
||||
{t("settings.notifications.enable")}
|
||||
{selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : t("settings.notifications.enable")}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedRegion === "GLOBAL" ? t("settings.notifications.enableGlobalDescription") : t("settings.notifications.enableDescription")}</span>
|
||||
<span className="mt-2 block text-xs font-semibold text-accent">
|
||||
{isSavingSubscription
|
||||
? t("settings.notifications.saving")
|
||||
|
||||
@@ -42,6 +42,7 @@ export function DashboardWarnings() {
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
const isGlobalLocation = selectedLocation?.region === "GLOBAL";
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => setNow(Date.now()), 0);
|
||||
@@ -52,10 +53,10 @@ export function DashboardWarnings() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
|
||||
const regionLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : province ? formatProvinceName(province, language) : null;
|
||||
const relevantWarnings = useMemo(() => {
|
||||
if (!warnings || !province || now === null) return [];
|
||||
if (isGlobalLocation || !warnings || !province || now === null) return [];
|
||||
|
||||
return warnings
|
||||
.filter((warning) => {
|
||||
@@ -65,7 +66,7 @@ export function DashboardWarnings() {
|
||||
&& (validTo === null || validTo > now);
|
||||
})
|
||||
.sort((a, b) => compareDashboardWarnings(a, b, now));
|
||||
}, [now, province, selectedLocation, warnings]);
|
||||
}, [isGlobalLocation, now, province, selectedLocation, warnings]);
|
||||
|
||||
if (!province || !regionLabel || !relevantWarnings.length || now === null) return null;
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export function WarningsPanel() {
|
||||
const { data: warnings, isPending, isError, refetch } = useWarnings();
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
if (selectedLocation?.region === "GLOBAL") {
|
||||
return <EmptyState title={t("warnings.globalUnavailableTitle")} description={t("warnings.globalUnavailableDescription")} />;
|
||||
}
|
||||
if (isPending) return <PageLoadingSkeleton />;
|
||||
if (isError) return <ErrorState onRetry={() => refetch()} description={t("warnings.error")} />;
|
||||
if (!warnings?.length) return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } fr
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { fetchReverseLocation } from "@/lib/location-api";
|
||||
import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils";
|
||||
import { createSelectedLocation, type LocatedSynopStation } from "@/lib/location-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
|
||||
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
|
||||
@@ -39,11 +39,6 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
setMessage(t("location.gpsUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!stations.length) {
|
||||
setMessage(t("location.gpsStationsPending"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async ({ coords }) => {
|
||||
@@ -54,14 +49,16 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
name: t("location.gpsFallbackName"),
|
||||
province: null,
|
||||
district: null,
|
||||
country: null,
|
||||
countryCode: null,
|
||||
admin1: null,
|
||||
admin2: null,
|
||||
timezone: null,
|
||||
region: "GLOBAL" as const,
|
||||
}));
|
||||
const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations);
|
||||
if (!nearest) {
|
||||
setMessage(t("location.gpsStationsPending"));
|
||||
return;
|
||||
}
|
||||
selectLocation(nearest);
|
||||
setMessage(t("location.gpsSelected", { location: nearest.name }));
|
||||
const selected = createSelectedLocation({ ...place, latitude, longitude }, stations);
|
||||
selectLocation(selected);
|
||||
setMessage(t("location.gpsSelected", { location: selected.name }));
|
||||
} catch {
|
||||
setMessage(t("location.gpsPositionUnavailable"));
|
||||
} finally {
|
||||
@@ -89,13 +86,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return;
|
||||
if (!window.isSecureContext || autoLocated.current || !navigator.permissions?.query) return;
|
||||
navigator.permissions.query({ name: "geolocation" }).then((permission) => {
|
||||
if (permission.state !== "granted" || autoLocated.current) return;
|
||||
autoLocated.current = true;
|
||||
locate();
|
||||
}).catch(() => undefined);
|
||||
}, [locate, stations.length]);
|
||||
}, [locate]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -108,7 +105,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
<p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
|
||||
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
|
||||
<Button type="button" onClick={locate} disabled={isLocating}>
|
||||
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
|
||||
</Button>
|
||||
@@ -120,7 +117,7 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
|
||||
)}
|
||||
{!showPrompt && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<Button type="button" variant="glass" onClick={locate} disabled={isLocating || !stations.length}>
|
||||
<Button type="button" variant="glass" onClick={locate} disabled={isLocating}>
|
||||
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
|
||||
</Button>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
|
||||
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
||||
import { useLocationSearch } from "@/hooks/use-location-search";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||
import type { SelectedLocation } from "@/types/location";
|
||||
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
||||
|
||||
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
||||
const { language, t } = useI18n();
|
||||
@@ -20,10 +20,10 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
|
||||
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
|
||||
location,
|
||||
nearest: findNearestSynopStation(location, locatedStations),
|
||||
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]);
|
||||
selected: createSelectedLocation(location, locatedStations),
|
||||
})), [locatedStations, locations]);
|
||||
const showSuggestions = isFocused && query.trim().length >= 2;
|
||||
const isPreparingStations = positions.length === 0;
|
||||
const isPreparingStations = positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
|
||||
|
||||
function chooseLocation(location: SelectedLocation) {
|
||||
selectLocation(location);
|
||||
@@ -32,7 +32,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
const firstSuggestion = suggestions[0]?.nearest;
|
||||
const firstSuggestion = suggestions[0]?.selected;
|
||||
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
|
||||
event.preventDefault();
|
||||
chooseLocation(firstSuggestion);
|
||||
@@ -66,18 +66,22 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
{isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
|
||||
isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
|
||||
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
|
||||
suggestions.map(({ location, nearest }) => nearest && (
|
||||
suggestions.map(({ location, selected }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={location.id}
|
||||
onClick={() => chooseLocation(nearest)}
|
||||
onClick={() => chooseLocation(selected)}
|
||||
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{location.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted">{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
|
||||
{selected.stationName && selected.distanceKm !== null ? (
|
||||
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{selected.stationName} · {selected.distanceKm} km</strong></span>
|
||||
) : (
|
||||
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.modelSource")}<br /><strong className="font-semibold text-foreground">Open-Meteo</strong></span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -86,7 +90,9 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
||||
<CurrentLocationControl stations={locatedStations} />
|
||||
{selectedLocation && (
|
||||
<p className="mt-3 px-1 text-xs text-muted">
|
||||
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
||||
{selectedLocation.stationName && selectedLocation.distanceKm !== null
|
||||
? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })
|
||||
: t("location.currentSourceGlobal", { location: selectedLocation.name })}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 px-1 text-[0.68rem] text-muted">
|
||||
|
||||
@@ -42,18 +42,19 @@ function readDevWeatherEffectOverride(): DevWeatherEffectOverride | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number }) {
|
||||
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, currentForecastWeatherCode, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; currentForecastWeatherCode?: number | null; locationName?: string; distanceKm?: number | null }) {
|
||||
const { language, t } = useI18n();
|
||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
||||
const [devEffectOverride, setDevEffectOverride] = useState<DevWeatherEffectOverride | null>(null);
|
||||
const displayedLocationName = locationName ?? station.name;
|
||||
const hasGlobalModel = currentWeather?.coverage === "global-model";
|
||||
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
|
||||
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
|
||||
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
|
||||
const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30;
|
||||
const displayedStation = currentWeather ? {
|
||||
...station,
|
||||
measuredAt: hasFullHybridAnalysis ? currentWeather.measuredAt : station.measuredAt,
|
||||
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
|
||||
temperature: currentWeather.temperature ?? station.temperature,
|
||||
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
|
||||
windDirection: currentWeather.windDirection ?? station.windDirection,
|
||||
@@ -67,7 +68,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
||||
const metrics = [
|
||||
{ icon: Droplets, label: t("weather.humidity"), value: formatHumidity(displayedStation.humidity, 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: hasGlobalModel ? t("weather.currentPrecipitation") : 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) },
|
||||
];
|
||||
const effectPrecipitation10m = devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
|
||||
@@ -116,6 +117,8 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
<p>{currentWeatherLoading
|
||||
? t("location.heroHybridLoading", { station: station.name })
|
||||
: hasGlobalModel
|
||||
? t("location.heroGlobalModelSource", { location: displayedLocationName })
|
||||
: hasFullHybridAnalysis
|
||||
? t("location.heroHybridSource", { location: displayedLocationName })
|
||||
: hasPartialHybridAnalysis
|
||||
@@ -133,7 +136,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
|
||||
{formatTemperature(displayedStation.temperature, language, temperatureUnit)}
|
||||
</div>
|
||||
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{weatherDescription}</p>
|
||||
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
||||
<p className="mt-1 text-sm text-muted">{t("weather.feelsLike")} {formatTemperature(feelsLike, language, temperatureUnit)} · {hasGlobalModel ? t("weather.modelUpdate") : t("weather.measurement")} {formatDateTime(displayedStation.measuredAt, language)}</p>
|
||||
</div>
|
||||
<WeatherIcon mood={mood} condition={currentWeather?.condition} className="mb-4 size-20 text-accent sm:size-28" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user