chore: add prettier formatting
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s

This commit is contained in:
zv
2026-06-14 20:26:56 +02:00
parent 8bbd9397a1
commit ee55521803
79 changed files with 2451 additions and 969 deletions

View File

@@ -33,13 +33,35 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.temperatureChartDescription")}</p>
<div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={INITIAL_CHART_DIMENSION}
>
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit={temperatureUnitLabel} />
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
interval={3}
tick={{ fill: "currentColor", fontSize: 11 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
unit={temperatureUnitLabel}
/>
<Tooltip
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
contentStyle={{
borderRadius: 14,
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
background: CHART_COLORS.tooltipBackground,
color: CHART_COLORS.tooltipText,
}}
formatter={(value) => [
typeof value === "number"
? formatTemperatureValue(value, language, temperatureUnit)
@@ -47,8 +69,25 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
<Line
type="monotone"
dataKey="temperature"
name={t("forecast.temperature")}
stroke={CHART_COLORS.temperature}
strokeWidth={3}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="feelsLike"
name={t("forecast.apparentTemperature")}
stroke={CHART_COLORS.feelsLike}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -58,14 +97,45 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
<p className="mt-1 text-xs leading-5 text-muted">{t("forecast.rainfallChartDescription")}</p>
<div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={INITIAL_CHART_DIMENSION}
>
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
<CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
interval={3}
tick={{ fill: "currentColor", fontSize: 11 }}
/>
<YAxis
yAxisId="rainfall"
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
unit=" mm"
/>
<YAxis
yAxisId="probability"
orientation="right"
domain={[0, 100]}
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 11 }}
unit="%"
/>
<Tooltip
contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
contentStyle={{
borderRadius: 14,
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
background: CHART_COLORS.tooltipBackground,
color: CHART_COLORS.tooltipText,
}}
formatter={(value, name) => [
name === t("forecast.precipitation")
? formatForecastRainfall(typeof value === "number" ? value : null, language)
@@ -74,8 +144,23 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]}
/>
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} />
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls />
<Bar
yAxisId="rainfall"
dataKey="precipitation"
name={t("forecast.precipitation")}
fill={CHART_COLORS.rainfall}
radius={[5, 5, 0, 0]}
/>
<Line
yAxisId="probability"
type="monotone"
dataKey="precipitationProbability"
name={t("forecast.precipitationProbability")}
stroke={CHART_COLORS.probability}
strokeWidth={2}
dot={false}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>

View File

@@ -8,23 +8,28 @@ import { useWeatherStore } from "@/lib/store";
import { convertWindSpeed, getWindSpeedUnitLabel } from "@/lib/weather-utils";
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))"];
export function SnapshotChart({ station }: { station: SynopStation }) {
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 windSpeed =
station.windSpeed === null ? null : Number(convertWindSpeed(station.windSpeed, windSpeedUnit).toFixed(windDigits));
const windMax = windSpeedUnit === "ms" ? 20 : windSpeedUnit === "kmh" ? 72 : 45;
const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
{ name: t("weather.wind"), value: windSpeed, unit: getWindSpeedUnitLabel(windSpeedUnit), max: windMax, 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] },
].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) }));
return (
<Card className="p-5">
@@ -32,13 +37,31 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
<p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
<div className="mt-5 h-52 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={INITIAL_CHART_DIMENSION}
>
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
<XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
<Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
<YAxis
type="category"
dataKey="name"
width={86}
axisLine={false}
tickLine={false}
tick={{ fill: "currentColor", fontSize: 12 }}
/>
<Tooltip
cursor={{ fill: "hsl(var(--border) / 0.22)" }}
formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]}
/>
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
{rows.map((row) => <Cell fill={row.color} key={row.name} />)}
{rows.map((row) => (
<Cell fill={row.color} key={row.name} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>

View File

@@ -27,44 +27,78 @@ export function DashboardPage() {
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
const dashboardSections = useWeatherStore((state) => state.dashboardSections);
const selectedStation = stations?.find((station) => station.id === selectedStationId)
?? stations?.find((station) => station.name === DEFAULT_STATION_NAME)
?? stations?.[0];
const selectedStation =
stations?.find((station) => station.id === selectedStationId) ??
stations?.find((station) => station.name === DEFAULT_STATION_NAME) ??
stations?.[0];
const activeLocation = selectedLocation;
const activeRegion = activeLocation?.region ?? "PL";
const stationPosition = selectedStation
? locateSynopStations(stations ?? [], positions).find((station) => station.id === selectedStation.id)
: null;
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
const hasActiveLocationCoordinates =
Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
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, activeRegion);
const forecastLocationName = hasActiveLocationCoordinates
? (activeLocation?.name ?? selectedStation?.name)
: selectedStation?.name;
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;
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")} />;
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;
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={heroStation} 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 />
{dashboardSections.weatherBrief && <WeatherBriefCard latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />}
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} region={activeRegion} locationName={forecastLocationName ?? selectedStation.name} />
{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>

View File

@@ -15,7 +15,17 @@ import { useWeatherStore } from "@/lib/store";
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
import type { WeatherRegion } from "@/types/weather-region";
export function WeatherBriefCard({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; 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, region);
const { data: warnings = [] } = useWarnings();
@@ -24,17 +34,57 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const isGlobalLocation = region === "GLOBAL";
const province = isGlobalLocation ? null : getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
const relevantWarnings = useMemo(() => isGlobalLocation ? [] : warnings, [isGlobalLocation, warnings]);
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: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, 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: relevantWarnings, province, countyTeryt: isGlobalLocation ? null : selectedLocation?.countyTeryt, locationName, language, temperatureUnit, windSpeedUnit });
}, [forecast, isGlobalLocation, language, locationName, province, relevantWarnings, selectedLocation?.countyTeryt, temperatureUnit, 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" />;
@@ -61,11 +111,25 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
return (
<Card className="p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className={isWarning ? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning" : "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"}>
{isWarning ? <AlertTriangle className="size-4" aria-hidden="true" /> : <CloudSun className="size-4" aria-hidden="true" />}
<div
className={
isWarning
? "rounded-control border border-warning/30 bg-warning/10 p-2 text-warning"
: "rounded-control border border-border/70 bg-surface-muted/70 p-2 text-accent"
}
>
{isWarning ? (
<AlertTriangle className="size-4" aria-hidden="true" />
) : (
<CloudSun className="size-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0">
<p className={isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"}>
<p
className={
isWarning ? "text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning" : "section-kicker"
}
>
{t("brief.label")}
</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">{brief.headline}</h2>
@@ -75,7 +139,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
<ul className="mt-4 space-y-2">
{brief.body.slice(0, 4).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
<li
key={line}
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
>
{line}
</li>
))}
@@ -87,7 +154,10 @@ export function WeatherBriefCard({ latitude, longitude, region = "PL", locationN
<h3 className="mt-1 text-sm font-semibold">{tomorrowBrief.headline}</h3>
<ul className="mt-3 space-y-2">
{tomorrowBrief.body.slice(0, 3).map((line) => (
<li key={line} className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6">
<li
key={line}
className="rounded-card border border-border/60 bg-surface-muted/45 px-3 py-2 text-sm leading-6"
>
{line}
</li>
))}

View File

@@ -82,7 +82,9 @@ export function DayForecastModal({
}, [day, onClose]);
const formattedDate = day
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(new Date(`${day.date}T12:00:00Z`))
? new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" }).format(
new Date(`${day.date}T12:00:00Z`),
)
: "";
const modal = (
@@ -114,7 +116,9 @@ export function DayForecastModal({
<CloudSun className="size-4" />
{t("forecast.dayDetails")}
</p>
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
{locationName}
</h2>
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
</div>
<button
@@ -134,13 +138,25 @@ export function DayForecastModal({
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
<div className="mt-2 flex items-end gap-4">
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</p>
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</p>
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">
{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}
</p>
<p className="mb-2 text-2xl text-muted">
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
</p>
</div>
</div>
<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={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language, windSpeedUnit)} />
<DayMetric
icon={Droplets}
label={t("forecast.precipitation")}
value={formatForecastRainfall(day.precipitation, 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={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
</div>
@@ -160,8 +176,14 @@ export function DayForecastModal({
title={getForecastCondition(weatherCode, language)}
>
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<ForecastIcon code={weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" />
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
<ForecastIcon
code={weatherCode}
isNight={isForecastNight(hour.time, day)}
className="mx-auto my-3 size-6 text-accent"
/>
<p className="text-lg font-semibold tracking-tight">
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
</p>
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
<Droplets className="size-3" />
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}

View File

@@ -1,13 +1,44 @@
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudMoon, CloudRain, CloudSnow, CloudSun, MoonStar, Sun } from "lucide-react";
import {
Cloud,
CloudDrizzle,
CloudFog,
CloudLightning,
CloudMoon,
CloudRain,
CloudSnow,
CloudSun,
MoonStar,
Sun,
} from "lucide-react";
export function ForecastIcon({ code, className = "", isNight = false }: { code: number | null; className?: string; isNight?: boolean }) {
const Icon = code === 0 ? isNight ? MoonStar : Sun
: code === 1 || code === 2 ? isNight ? CloudMoon : CloudSun
: code === 45 || code === 48 ? CloudFog
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow
: code !== null && code >= 95 ? CloudLightning
: Cloud;
export function ForecastIcon({
code,
className = "",
isNight = false,
}: {
code: number | null;
className?: string;
isNight?: boolean;
}) {
const Icon =
code === 0
? isNight
? MoonStar
: Sun
: code === 1 || code === 2
? isNight
? CloudMoon
: CloudSun
: code === 45 || code === 48
? CloudFog
: code !== null && code >= 51 && code <= 57
? CloudDrizzle
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))
? CloudRain
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)
? CloudSnow
: code !== null && code >= 95
? CloudLightning
: Cloud;
return <Icon className={className} strokeWidth={1.45} />;
}

View File

@@ -2,7 +2,18 @@
import { useCallback, useState } from "react";
import { motion } from "framer-motion";
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, RefreshCw, ThermometerSun, Wind, type LucideIcon } from "lucide-react";
import {
CalendarDays,
ChevronRight,
Clock3,
CloudRain,
CloudSun,
Droplets,
RefreshCw,
ThermometerSun,
Wind,
type LucideIcon,
} from "lucide-react";
import { DayForecastCharts } from "@/components/charts/day-forecast-charts";
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
import { ForecastIcon } from "@/components/forecast/forecast-icon";
@@ -71,24 +82,47 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
const maximumRainfallProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
const temperatureRange = minimumTemperature === null || maximumTemperature === null
? "—"
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
const temperatureRange =
minimumTemperature === null || maximumTemperature === null
? "—"
: `${formatForecastTemperature(minimumTemperature, language, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
return (
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
<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">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<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={Droplets} label={t("forecast.maxProbability")} value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`} />
<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={Droplets}
label={t("forecast.maxProbability")}
value={maximumRainfallProbability === null ? "—" : `${maximumRainfallProbability}%`}
/>
</div>
</div>
);
}
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
function DailyForecastRow({
day,
index,
onSelect,
}: {
day: DailyForecast;
index: number;
onSelect: (day: DailyForecast) => void;
}) {
const { language, locale, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const label = formatDay(day.date, locale, t("forecast.today"), index);
@@ -109,17 +143,37 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
<p className="text-sm font-semibold capitalize">{label}</p>
<div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
<span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
<span className="truncate text-xs text-muted">
{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}
</span>
</div>
<span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}</span></p>
<span className="hidden items-center gap-1 text-xs text-accent sm:flex">
<Droplets className="size-3" />
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}
</span>
<p className="whitespace-nowrap text-sm">
<strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong>
<span className="ml-2 text-muted">
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
</span>
</p>
<ChevronRight className="hidden size-4 text-muted sm:block" />
</motion.button>
</motion.li>
);
}
export function ForecastPanel({ latitude, longitude, region = "PL", locationName }: { latitude?: number; longitude?: number; region?: WeatherRegion; 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);
@@ -133,9 +187,14 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
return (
<section className="space-y-3">
<div>
<p className="section-kicker flex items-center gap-2"><CloudSun className="size-4" />{t("forecast.label")}</p>
<p className="section-kicker flex items-center gap-2">
<CloudSun className="size-4" />
{t("forecast.label")}
</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">{t("forecast.description", { location: locationName })}</p>
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">
{t("forecast.description", { location: locationName })}
</p>
</div>
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
@@ -146,7 +205,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
) : isError || !forecast ? (
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-muted">{t("forecast.error")}</p>
<Button variant="glass" className="mt-4" onClick={() => refetch()}><RefreshCw className="size-4" />{t("common.retry")}</Button>
<Button variant="glass" className="mt-4" onClick={() => refetch()}>
<RefreshCw className="size-4" />
{t("common.retry")}
</Button>
</Card>
) : !forecast.hourly.length || !forecast.daily.length ? (
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
@@ -154,7 +216,10 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
<div className="space-y-3">
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-accent" />{t("forecast.hourly")}</h3>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Clock3 className="size-4 text-accent" />
{t("forecast.hourly")}
</h3>
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
<ul className="flex min-w-max gap-2">
{upcomingHours.map((hour, index) => {
@@ -170,11 +235,23 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
title={getForecastCondition(weatherCode, language)}
>
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<ForecastIcon code={weatherCode} isNight={isForecastNight(hour.time, day)} className="mx-auto my-3 size-6 text-accent" />
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language, temperatureUnit)}</p>
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
<ForecastIcon
code={weatherCode}
isNight={isForecastNight(hour.time, day)}
className="mx-auto my-3 size-6 text-accent"
/>
<p className="text-lg font-semibold tracking-tight">
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
</p>
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
<Droplets className="size-3" />
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
</p>
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
<p
className="flex items-center justify-center gap-1"
title={t("forecast.apparentTemperature")}
>
<ThermometerSun className="size-3 text-accent" />
{formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
</p>
@@ -195,8 +272,15 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
<HourlyForecastSummary hours={upcomingHours} />
</Card>
<Card className="p-4 sm:p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3>
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<CalendarDays className="size-4 text-accent" />
{t("forecast.daily")}
</h3>
<ul className="mt-2">
{forecast.daily.map((day, index) => (
<DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />
))}
</ul>
</Card>
</div>
<DayForecastCharts hours={todayHours} />
@@ -205,7 +289,13 @@ export function ForecastPanel({ latitude, longitude, region = "PL", locationName
{forecast && <ForecastSources sources={forecast.sources} />}
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} sources={forecast?.sources ?? []} onClose={closeDayDetails} />
<DayForecastModal
day={selectedDay}
hours={selectedDayHours}
locationName={locationName}
sources={forecast?.sources ?? []}
onClose={closeDayDetails}
/>
</section>
);
}

View File

@@ -13,13 +13,23 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
{t("forecast.source")}{" "}
{hasImgw && (
<>
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
<a
href="https://meteo.imgw.pl/pogoda/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
IMGW ALARO <ExternalLink className="size-3" />
</a>
{", "}
</>
)}
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
<a
href="https://open-meteo.com/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
Open-Meteo <ExternalLink className="size-3" />
</a>
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}

View File

@@ -17,10 +17,14 @@ export function HydroPage() {
const { data: stations, isPending, isError, refetch } = useHydroStations();
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const filteredStations = useMemo(() => (stations ?? []).filter((station) => {
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
return haystack.includes(query.trim().toLocaleLowerCase(locale));
}), [locale, query, stations]);
const filteredStations = useMemo(
() =>
(stations ?? []).filter((station) => {
const haystack = `${station.name} ${station.river ?? ""} ${station.province ?? ""}`.toLocaleLowerCase(locale);
return haystack.includes(query.trim().toLocaleLowerCase(locale));
}),
[locale, query, stations],
);
if (isPending) return <PageLoadingSkeleton />;
if (isError) return <ErrorState onRetry={() => refetch()} description={t("hydro.error")} />;
@@ -34,13 +38,38 @@ export function HydroPage() {
<label className="glass relative block rounded-panel p-3">
<span className="sr-only">{t("hydro.searchLabel")}</span>
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm" />
<input
value={query}
onChange={(event) => {
setQuery(event.target.value);
setVisibleCount(PAGE_SIZE);
}}
placeholder={t("hydro.searchPlaceholder")}
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
/>
</label>
<p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
<p className="text-xs text-muted">
{t("hydro.results", {
total: filteredStations.length,
visible: Math.min(visibleCount, filteredStations.length),
})}
</p>
{!filteredStations.length ? (
<EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} />
) : (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>
{visibleCount < filteredStations.length && <div className="flex justify-center pt-2"><Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>{t("hydro.more")}</Button></div>}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredStations.slice(0, visibleCount).map((station, index) => (
<HydroStationCard key={station.id} station={station} index={index} />
))}
</div>
{visibleCount < filteredStations.length && (
<div className="flex justify-center pt-2">
<Button variant="glass" onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}>
{t("hydro.more")}
</Button>
</div>
)}
</>
)}
</div>

View File

@@ -12,20 +12,38 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
return (
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
<motion.article
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}
>
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<div>
<div>
<h2 className="font-semibold tracking-tight">{station.name}</h2>
<p className="mt-1 flex items-center gap-1 text-xs text-muted"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
<MapPin className="size-3" />
{station.river ?? t("hydro.riverUnavailable")}
{station.province ? ` · ${station.province}` : ""}
</p>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
<HydroMetric icon={Droplets} label={t("hydro.level")} value={formatWaterLevel(station.waterLevel, language)} />
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language, temperatureUnit)} />
<HydroMetric
icon={Droplets}
label={t("hydro.level")}
value={formatWaterLevel(station.waterLevel, language)}
/>
<HydroMetric
icon={Thermometer}
label={t("hydro.water")}
value={formatTemperature(station.waterTemperature, language, temperatureUnit)}
/>
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
</div>
<p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
<p className="mt-4 text-[0.7rem] text-muted">
{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}
</p>
</Card>
</motion.article>
);
@@ -34,8 +52,13 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
return (
<div className="rounded-card bg-surface-muted/60 p-2.5">
<p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
<p className="flex items-center gap-1 text-[0.65rem] text-muted">
<Icon className="size-3" />
{label}
</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>
{value}
</p>
</div>
);
}

View File

@@ -28,14 +28,24 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="min-h-screen overflow-x-hidden bg-background">
<header className="app-header-safe sticky top-0 z-40 border-b border-border/70 bg-surface">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<Link
href="/"
className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
wtr<span className="text-accent">.</span>
</Link>
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
{visibleNavItems.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link key={item.href} href={item.href} className={cn("rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70")}>
<Link
key={item.href}
href={item.href}
className={cn(
"rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70",
)}
>
{t(item.labelKey)}
</Link>
);
@@ -47,12 +57,22 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
</header>
<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 aria-label={t("nav.mobile")} className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden">
<nav
aria-label={t("nav.mobile")}
className="mobile-nav-safe fixed z-50 flex justify-around rounded-panel border border-border/70 bg-surface p-1.5 shadow-card md:hidden"
>
{visibleNavItems.map((item) => {
const Icon = iconsByHref[item.href] ?? CloudSun;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link key={item.href} href={item.href} className={cn("flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
<Link
key={item.href}
href={item.href}
className={cn(
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-card px-2 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active ? "bg-foreground text-background" : "text-muted",
)}
>
<Icon className="size-4" />
<span className="max-w-full truncate">{t(item.labelKey)}</span>
</Link>

View File

@@ -1,7 +1,20 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Bell, BellRing, ChevronDown, Clock3, Languages, LayoutDashboard, MapPinned, Palette, Settings, Smartphone, Thermometer, Wind } from "lucide-react";
import {
Bell,
BellRing,
ChevronDown,
Clock3,
Languages,
LayoutDashboard,
MapPinned,
Palette,
Settings,
Smartphone,
Thermometer,
Wind,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -14,13 +27,26 @@ import { DEFAULT_STATION_ID } from "@/lib/constants";
import { DASHBOARD_SECTION_SETTINGS } from "@/lib/dashboard-sections";
import { useI18n } from "@/lib/i18n";
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 { useWeatherStore } from "@/lib/store";
import { TEMPERATURE_UNITS, WIND_SPEED_UNITS } from "@/lib/weather-utils";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
type NotificationSupportStatus =
| "checking"
| "unconfigured"
| "unsupported"
| "needs-install"
| "default"
| "denied"
| "granted";
function isStandaloneApp() {
if (typeof window === "undefined") return false;
@@ -38,12 +64,23 @@ function isAppleMobilePlatform() {
function getNotificationSupportStatus(): NotificationSupportStatus {
if (typeof window === "undefined") return "checking";
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window))
return "unsupported";
if (isAppleMobilePlatform() && !isStandaloneApp()) return "needs-install";
return Notification.permission;
}
function SettingsRow({ icon: Icon, title, description, children }: { icon: typeof Settings; title: string; description: string; children: React.ReactNode }) {
function SettingsRow({
icon: Icon,
title,
description,
children,
}: {
icon: typeof Settings;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-4 border-t border-border/65 py-4 first:border-t-0 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
@@ -64,24 +101,36 @@ function SwitchIndicator({ checked, disabled }: { checked: boolean; disabled?: b
return (
<span
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-control border px-0.5 transition-colors ${
checked
? "border-accent/55 bg-accent"
: "border-border/70 bg-surface-muted"
checked ? "border-accent/55 bg-accent" : "border-border/70 bg-surface-muted"
} ${disabled ? "opacity-55" : ""}`}
aria-hidden="true"
>
<span className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`} />
<span
className={`size-5 rounded-full bg-surface-raised shadow-soft transition-transform ${checked ? "translate-x-5" : "translate-x-0"}`}
/>
</span>
);
}
function NotificationSwitchLabel({ icon: Icon, title, description, checked, disabled, onChange }: { icon: LucideIcon; title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
function NotificationSwitchLabel({
icon: Icon,
title,
description,
checked,
disabled,
onChange,
}: {
icon: LucideIcon;
title: string;
description: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`mt-3 flex cursor-pointer items-start justify-between gap-4 rounded-card border px-3 py-3 text-sm transition-colors ${
checked
? "border-accent/45 bg-accent/10"
: "border-border/60 bg-surface"
checked ? "border-accent/45 bg-accent/10" : "border-border/60 bg-surface"
} ${disabled ? "cursor-not-allowed opacity-65" : "hover:border-accent/45"}`}
>
<input
@@ -103,7 +152,10 @@ function NotificationSwitchLabel({ icon: Icon, title, description, checked, disa
);
}
const windSpeedUnitKeys: Record<WindSpeedUnit, "settings.units.wind.ms" | "settings.units.wind.kmh" | "settings.units.wind.mph"> = {
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",
@@ -145,25 +197,37 @@ export function SettingsPage() {
const setDashboardSectionVisible = useWeatherStore((state) => state.setDashboardSectionVisible);
const setAppSectionVisible = useWeatherStore((state) => state.setAppSectionVisible);
const selectedStation = stations?.find((station) => station.id === selectedStationId)
?? stations?.find((station) => station.id === DEFAULT_STATION_ID)
?? stations?.[0];
const selectedStation =
stations?.find((station) => station.id === selectedStationId) ??
stations?.find((station) => station.id === DEFAULT_STATION_ID) ??
stations?.[0];
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 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 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 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 effectiveProvinceLabel =
selectedRegion === "GLOBAL"
? t("settings.notifications.globalRegion")
: effectiveProvince
? formatProvinceName(effectiveProvince, language)
: t("settings.notifications.noProvince");
const manualProvinceValue = manualProvince ?? selectedProvince ?? "mazowieckie";
useEffect(() => {
@@ -211,7 +275,24 @@ export function SettingsPage() {
return () => {
cancelled = true;
};
}, [canUseOfficialWarnings, effectiveProvince, language, morningBriefEnabled, notificationCountyTeryt, notificationLatitude, notificationLocationName, notificationLongitude, notificationStatus, notificationsEnabled, notificationTimezone, selectedRegion, temperatureUnit, tomorrowBriefEnabled, vapidPublicKey, windSpeedUnit]);
}, [
canUseOfficialWarnings,
effectiveProvince,
language,
morningBriefEnabled,
notificationCountyTeryt,
notificationLatitude,
notificationLocationName,
notificationLongitude,
notificationStatus,
notificationsEnabled,
notificationTimezone,
selectedRegion,
temperatureUnit,
tomorrowBriefEnabled,
vapidPublicKey,
windSpeedUnit,
]);
useEffect(() => {
if (!provinceDropdownOpen) return;
@@ -243,11 +324,18 @@ export function SettingsPage() {
}
}, [notificationStatus, t]);
const canUsePush = Boolean(vapidPublicKey && (selectedRegion === "GLOBAL" || 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.");
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js");
return (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.register("/sw.js"));
}
async function enableWarningNotifications() {
@@ -270,10 +358,12 @@ export function SettingsPage() {
}
const registration = await getServiceWorkerRegistration();
const existingSubscription = await registration.pushManager.getSubscription();
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
});
const subscription =
existingSubscription ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
}));
await savePushSubscription(subscription, effectiveProvince, language, {
region: selectedRegion,
officialWarningsEnabled: canUseOfficialWarnings,
@@ -387,20 +477,35 @@ export function SettingsPage() {
return (
<div className="space-y-6">
<section>
<p className="section-kicker flex items-center gap-2"><Settings className="size-4" />{t("settings.section")}</p>
<p className="section-kicker flex items-center gap-2">
<Settings className="size-4" />
{t("settings.section")}
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("settings.title")}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("settings.description")}</p>
</section>
<Card className="p-4 sm:p-5">
<SettingsRow icon={Languages} title={t("settings.language.title")} description={t("settings.language.description")}>
<SettingsRow
icon={Languages}
title={t("settings.language.title")}
description={t("settings.language.description")}
>
<LanguageToggle />
</SettingsRow>
<SettingsRow icon={Palette} title={t("settings.theme.title")} description={t("settings.theme.description")}>
<ThemeToggle />
</SettingsRow>
<SettingsRow icon={Thermometer} title={t("settings.units.temperature.title")} description={t("settings.units.temperature.description")}>
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.temperature.label")}>
<SettingsRow
icon={Thermometer}
title={t("settings.units.temperature.title")}
description={t("settings.units.temperature.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.temperature.label")}
>
{TEMPERATURE_UNITS.map((unit) => (
<button
key={unit}
@@ -419,8 +524,16 @@ export function SettingsPage() {
))}
</div>
</SettingsRow>
<SettingsRow icon={Wind} title={t("settings.units.wind.title")} description={t("settings.units.wind.description")}>
<div className="surface-control inline-flex rounded-full p-1" role="radiogroup" aria-label={t("settings.units.wind.label")}>
<SettingsRow
icon={Wind}
title={t("settings.units.wind.title")}
description={t("settings.units.wind.description")}
>
<div
className="surface-control inline-flex rounded-full p-1"
role="radiogroup"
aria-label={t("settings.units.wind.label")}
>
{WIND_SPEED_UNITS.map((unit) => (
<button
key={unit}
@@ -509,7 +622,9 @@ export function SettingsPage() {
<MapPinned className="mt-0.5 size-4 text-accent" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold">{t("settings.notifications.regionTitle")}</h3>
<p className="mt-1 text-sm leading-6 text-muted">{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}</p>
<p className="mt-1 text-sm leading-6 text-muted">
{t("settings.notifications.regionDescription", { province: effectiveProvinceLabel })}
</p>
</div>
</div>
@@ -525,7 +640,11 @@ export function SettingsPage() {
/>
<span>
<span className="block font-medium">{t("settings.notifications.regionSelected")}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">{selectedProvince ? formatProvinceName(selectedProvince, language) : t("settings.notifications.noProvince")}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted">
{selectedProvince
? formatProvinceName(selectedProvince, language)
: t("settings.notifications.noProvince")}
</span>
</span>
</label>
<label className="flex items-start gap-3 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
@@ -550,7 +669,8 @@ export function SettingsPage() {
aria-expanded={provinceDropdownOpen}
disabled={selectedRegion === "GLOBAL" || provinceMode !== "manual"}
onBlur={(event) => {
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) setProvinceDropdownOpen(false);
if (!event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null))
setProvinceDropdownOpen(false);
}}
onClick={() => setProvinceDropdownOpen((current) => !current)}
className="surface-control flex w-full items-center justify-between gap-3 rounded-control py-2 pl-3 pr-3 text-left text-sm disabled:opacity-60"
@@ -581,7 +701,9 @@ export function SettingsPage() {
}`}
>
<span className="truncate">{formatProvinceName(province, language)}</span>
{manualProvinceValue === province && <span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />}
{manualProvinceValue === province && (
<span className="size-1.5 rounded-full bg-accent" aria-hidden="true" />
)}
</button>
))}
</div>
@@ -621,9 +743,15 @@ 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" />
{selectedRegion === "GLOBAL" ? t("settings.notifications.enableDevice") : 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">
{selectedRegion === "GLOBAL"
? t("settings.notifications.enableGlobalDescription")
: 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")
@@ -632,7 +760,10 @@ export function SettingsPage() {
: t("settings.notifications.disabledStatus")}
</span>
</span>
<SwitchIndicator checked={notificationsEnabled} disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} />
<SwitchIndicator
checked={notificationsEnabled}
disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription}
/>
</button>
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
@@ -655,9 +786,16 @@ export function SettingsPage() {
/>
<div className="mt-4">
<Button type="button" variant="glass" disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification} onClick={sendTestNotification}>
<Button
type="button"
variant="glass"
disabled={!notificationsEnabled || isSavingSubscription || isSendingTestNotification}
onClick={sendTestNotification}
>
<BellRing className="size-4" />
{isSendingTestNotification ? t("settings.notifications.sendingTest") : t("settings.notifications.sendTest")}
{isSendingTestNotification
? t("settings.notifications.sendingTest")
: t("settings.notifications.sendTest")}
</Button>
</div>

View File

@@ -2,10 +2,20 @@ import type { LucideIcon } from "lucide-react";
import { CircleCheckBig } from "lucide-react";
import { Card } from "@/components/ui/card";
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
export function EmptyState({
title,
description,
icon: Icon = CircleCheckBig,
}: {
title: string;
description: string;
icon?: LucideIcon;
}) {
return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent"><Icon className="size-6" /></div>
<div className="mb-4 rounded-control bg-accent/10 p-3 text-accent">
<Icon className="size-6" />
</div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
</Card>

View File

@@ -5,14 +5,27 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n";
export function ErrorState({ title, description, onRetry }: { title?: string; description?: string; onRetry: () => void }) {
export function ErrorState({
title,
description,
onRetry,
}: {
title?: string;
description?: string;
onRetry: () => void;
}) {
const { t } = useI18n();
return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning"><TriangleAlert className="size-6" /></div>
<div className="mb-4 rounded-control bg-warning/10 p-3 text-warning">
<TriangleAlert className="size-6" />
</div>
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p>
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
<Button variant="glass" onClick={onRetry}>
<RefreshCw className="size-4" />
{t("common.retry")}
</Button>
</Card>
);
}

View File

@@ -5,7 +5,12 @@ import { useI18n } from "@/lib/i18n";
export function LoadingSkeleton({ className = "" }: { className?: string }) {
const { t } = useI18n();
return <div className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)} aria-label={t("common.loading")} />;
return (
<div
className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)}
aria-label={t("common.loading")}
/>
);
}
export function PageLoadingSkeleton() {
@@ -13,7 +18,9 @@ export function PageLoadingSkeleton() {
<div className="space-y-5" aria-busy="true">
<LoadingSkeleton className="h-[25rem]" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => <LoadingSkeleton className="h-36" key={index} />)}
{Array.from({ length: 4 }, (_, index) => (
<LoadingSkeleton className="h-36" key={index} />
))}
</div>
</div>
);

View File

@@ -53,17 +53,25 @@ export function DashboardWarnings() {
};
}, []);
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 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 (isGlobalLocation || !warnings || !province || now === null) return [];
return warnings
.filter((warning) => {
const validTo = getTimestamp(warning.validTo);
return warning.kind === "meteo"
&& warningMatchesLocalSelection(warning, province, selectedLocation)
&& (validTo === null || validTo > now);
return (
warning.kind === "meteo" &&
warningMatchesLocalSelection(warning, province, selectedLocation) &&
(validTo === null || validTo > now)
);
})
.sort((a, b) => compareDashboardWarnings(a, b, now));
}, [isGlobalLocation, now, province, selectedLocation, warnings]);
@@ -87,12 +95,8 @@ export function DashboardWarnings() {
<AlertTriangle className="size-4" aria-hidden="true" />
</div>
<div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
IMGW · {regionLabel}
</p>
<h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")}
</h2>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">IMGW · {regionLabel}</p>
<h2 className="mt-1 text-base font-semibold text-foreground">{t("warnings.dashboard.title")}</h2>
</div>
</div>
<Link
@@ -109,13 +113,11 @@ export function DashboardWarnings() {
const active = isWarningActive(warning, now);
const validityLabel = active
? warning.validTo && t("warnings.dashboard.validUntil", { date: formatDateTime(warning.validTo, language) })
: warning.validFrom && t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
: warning.validFrom &&
t("warnings.dashboard.validFrom", { date: formatDateTime(warning.validFrom, language) });
return (
<article
key={warning.id}
className="rounded-card border border-warning/20 bg-surface px-3.5 py-3"
>
<article key={warning.id} className="rounded-card border border-warning/20 bg-surface px-3.5 py-3">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}

View File

@@ -12,25 +12,57 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
const { language, t } = useI18n();
const Icon = warning.kind === "hydro" ? Waves : CloudLightning;
const level = warning.level;
const levelLabel = level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
const areasLabel = warning.areas.length > 8
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
: warning.areas.join("; ");
const levelLabel =
level === -1 ? t("warnings.drought") : level === null ? t("warnings.levelUnknown") : t("warnings.level", { level });
const areasLabel =
warning.areas.length > 8
? `${warning.areas.slice(0, 8).join(", ")} ${t("warnings.moreAreas", { count: warning.areas.length - 8 })}`
: warning.areas.join("; ");
return (
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
<motion.article
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}
>
<Card className="h-full overflow-hidden p-5">
<div className="flex items-start justify-between gap-3">
<div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
<span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span>
<div className="rounded-card bg-warning/10 p-2.5 text-warning">
<Icon className="size-5" />
</div>
<span
className={cn(
"rounded-control border px-2.5 py-1 text-xs font-semibold",
level === -1
? "border-warning/30 bg-warning/10 text-warning"
: "border-warning/25 bg-warning/10 text-warning",
)}
>
{levelLabel}
</span>
</div>
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">
{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}
</p>
<h2 className="mt-2 text-lg font-semibold tracking-tight">
{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}
</h2>
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
<div className="mt-5 space-y-2 text-xs text-muted">
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
<p className="flex items-start gap-2">
<CalendarClock className="mt-0.5 size-3.5 shrink-0" />
{formatDateTime(warning.validFrom, language)} {" "}
{warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}
</p>
<p className="flex items-start gap-2">
<MapPinned className="mt-0.5 size-3.5 shrink-0" />
{areasLabel || t("warnings.areaUnknown")}
</p>
</div>
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-muted">{t("warnings.probability", { value: warning.probability })}</p>}
{warning.probability !== null && (
<p className="mt-4 text-xs font-medium text-muted">
{t("warnings.probability", { value: warning.probability })}
</p>
)}
</Card>
</motion.article>
);

View File

@@ -16,7 +16,9 @@ import type { WeatherWarning } from "@/types/imgw";
function WarningGrid({ warnings, indexOffset = 0 }: { warnings: WeatherWarning[]; indexOffset?: number }) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{warnings.map((warning, index) => <WarningCard key={warning.id} warning={warning} index={index + indexOffset} />)}
{warnings.map((warning, index) => (
<WarningCard key={warning.id} warning={warning} index={index + indexOffset} />
))}
</div>
);
}
@@ -27,37 +29,60 @@ export function WarningsPanel() {
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")} />;
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")} />;
if (!warnings?.length)
return <EmptyState title={t("warnings.emptyTitle")} description={t("warnings.emptyDescription")} />;
const province = getProvinceForSelection(selectedLocation?.province, selectedStationId ?? DEFAULT_STATION_ID);
if (!province) return <WarningGrid warnings={warnings} />;
const provinceLabel = formatProvinceName(province, language);
const localLabel = selectedLocation?.countyTeryt ? selectedLocation.district ?? selectedLocation.name : provinceLabel;
const localLabel = selectedLocation?.countyTeryt
? (selectedLocation.district ?? selectedLocation.name)
: provinceLabel;
const localWarnings = warnings.filter((warning) => warningMatchesLocalSelection(warning, province, selectedLocation));
const otherWarnings = warnings.filter((warning) => !warningMatchesLocalSelection(warning, province, selectedLocation));
const otherWarnings = warnings.filter(
(warning) => !warningMatchesLocalSelection(warning, province, selectedLocation),
);
return (
<div className="space-y-9">
<section className="space-y-4">
<div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p>
<p className="section-kicker flex items-center gap-2">
<MapPinned className="size-4" />
{t("warnings.myProvince")}
</p>
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{localLabel}</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: localLabel })}</p>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
{t("warnings.myProvinceDescription", { province: localLabel })}
</p>
</div>
{localWarnings.length
? <WarningGrid warnings={localWarnings} />
: <EmptyState title={t("warnings.myProvinceEmptyTitle")} description={t("warnings.myProvinceEmptyDescription", { province: localLabel })} />}
{localWarnings.length ? (
<WarningGrid warnings={localWarnings} />
) : (
<EmptyState
title={t("warnings.myProvinceEmptyTitle")}
description={t("warnings.myProvinceEmptyDescription", { province: localLabel })}
/>
)}
</section>
{otherWarnings.length > 0 && (
<section className="space-y-4">
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted">
<Map className="size-4" />
{t("warnings.otherRegions")}
</p>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
</div>
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />

View File

@@ -1,7 +1,15 @@
"use client";
import { CloudSun, Droplets, Gauge, Navigation, Thermometer, Umbrella, Wind } from "lucide-react";
import { calculateFeelsLike, formatHumidity, formatPressure, formatRainfall, formatTemperature, formatWind, getWindDirection } from "@/lib/weather-utils";
import {
calculateFeelsLike,
formatHumidity,
formatPressure,
formatRainfall,
formatTemperature,
formatWind,
getWindDirection,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw";
import { MetricCard } from "@/components/weather/metric-card";
import { useI18n } from "@/lib/i18n";
@@ -13,17 +21,57 @@ export function CurrentConditionsCard({ station }: { station: SynopStation }) {
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
const feelsLike = calculateFeelsLike(station.temperature, station.humidity, station.windSpeed);
const metrics = [
{ icon: Thermometer, label: t("weather.feelsLike"), value: formatTemperature(feelsLike, language, temperatureUnit), detail: t("weather.feelsLikeDetail") },
{ 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: 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: 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, temperatureUnit), detail: t("weather.temperatureDetail") },
{
icon: Thermometer,
label: t("weather.feelsLike"),
value: formatTemperature(feelsLike, language, temperatureUnit),
detail: t("weather.feelsLikeDetail"),
},
{
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: 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: 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, temperatureUnit),
detail: t("weather.temperatureDetail"),
},
];
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric, index) => <MetricCard {...metric} index={index} key={metric.label} />)}
{metrics.map((metric, index) => (
<MetricCard {...metric} index={index} key={metric.label} />
))}
</div>
);
}

View File

@@ -67,11 +67,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
},
(error) => {
setIsLocating(false);
setMessage(error.code === error.PERMISSION_DENIED
? t("location.gpsDenied")
: error.code === error.TIMEOUT
? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable"));
setMessage(
error.code === error.PERMISSION_DENIED
? t("location.gpsDenied")
: error.code === error.TIMEOUT
? t("location.gpsTimeout")
: t("location.gpsPositionUnavailable"),
);
},
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
);
@@ -87,11 +89,14 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
useEffect(() => {
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);
navigator.permissions
.query({ name: "geolocation" })
.then((permission) => {
if (permission.state !== "granted" || autoLocated.current) return;
autoLocated.current = true;
locate();
})
.catch(() => undefined);
}, [locate]);
return (
@@ -99,17 +104,27 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{showPrompt && (
<div className="glass-subtle rounded-card p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-control bg-accent/10 p-2 text-accent"><MapPinned className="size-4" /></div>
<div className="rounded-control bg-accent/10 p-2 text-accent">
<MapPinned className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<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>}
{!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}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</Button>
<Button type="button" variant="ghost" onClick={dismissPrompt}><X className="size-4" />{t("location.gpsNotNow")}</Button>
<Button type="button" variant="ghost" onClick={dismissPrompt}>
<X className="size-4" />
{t("location.gpsNotNow")}
</Button>
</div>
</div>
</div>
@@ -121,11 +136,23 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button>
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">{message}</p>}
{message && (
<p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">
{message}
</p>
)}
</div>
)}
<p className="text-[0.68rem] text-muted">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a>
{t("location.gpsAttribution")}{" "}
<a
href="https://www.openstreetmap.org/copyright"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
OpenStreetMap <ExternalLink className="size-3" />
</a>
</p>
</div>
);

View File

@@ -14,8 +14,15 @@ export function FavoritesSection({ stations }: { stations: SynopStation[] }) {
return (
<section className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold"><Heart className="size-4 fill-rose-500 text-rose-500" />{t("favorites.title")}</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{favorites.map((station, index) => <StationCard key={station.id} station={station} index={index} />)}</div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Heart className="size-4 fill-rose-500 text-rose-500" />
{t("favorites.title")}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{favorites.map((station, index) => (
<StationCard key={station.id} station={station} index={index} />
))}
</div>
</section>
);
}

View File

@@ -19,7 +19,10 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return (
<section className="space-y-3">
<div>
<p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("featured.label")}</p>
<p className="section-kicker flex items-center gap-2">
<MapPinned className="size-4" />
{t("featured.label")}
</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
@@ -30,11 +33,16 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
type="button"
key={station.id}
onClick={() => selectStation(station.id)}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active && "border-accent/60 bg-surface-raised/90")}
className={cn(
"glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
active && "border-accent/60 bg-surface-raised/90",
)}
>
<span className="min-w-0">
<span className="block truncate text-xs font-medium text-muted">{station.name}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language, temperatureUnit)}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">
{formatTemperature(station.temperature, language, temperatureUnit)}
</span>
</span>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
</button>

View File

@@ -10,7 +10,13 @@ import { useWeatherStore } from "@/lib/store";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
export function LocationSearch({
stations,
positions,
}: {
stations: SynopStation[];
positions: MeteoStationPosition[];
}) {
const { language, t } = useI18n();
const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false);
@@ -18,12 +24,17 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
const selectLocation = useWeatherStore((state) => state.selectLocation);
const { data: locations, isFetching, isError } = useLocationSearch(query, language);
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
location,
selected: createSelectedLocation(location, locatedStations),
})), [locatedStations, locations]);
const suggestions = useMemo(
() =>
(locations ?? []).map((location) => ({
location,
selected: createSelectedLocation(location, locatedStations),
})),
[locatedStations, locations],
);
const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
const isPreparingStations =
positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
function chooseLocation(location: SelectedLocation) {
selectLocation(location);
@@ -48,7 +59,11 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<div className="relative z-20">
<label className="relative block">
<span className="sr-only">{t("location.searchLabel")}</span>
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />}
{isFetching || isPreparingStations ? (
<LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" />
) : (
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />
)}
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -59,31 +74,57 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
autoComplete="off"
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
/>
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>}
{query && (
<button
type="button"
aria-label={t("location.clear")}
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X className="size-4" />
</button>
)}
</label>
{showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
{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, selected }) => (
<button
type="button"
key={location.id}
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.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}</span>
{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, selected }) => (
<button
type="button"
key={location.id}
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.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}
</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>
))}
</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>
)}
</div>
@@ -91,12 +132,24 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted">
{selectedLocation.stationName && selectedLocation.distanceKm !== null
? t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })
? 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">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a>
{t("location.attribution")}{" "}
<a
href="https://open-meteo.com/en/docs/geocoding-api"
target="_blank"
rel="noreferrer"
className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
>
Open-Meteo / GeoNames
</a>
</p>
</div>
</section>

View File

@@ -4,9 +4,25 @@ import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { icon: LucideIcon; label: string; value: string; detail?: string; index?: number }) {
export function MetricCard({
icon: Icon,
label,
value,
detail,
index = 0,
}: {
icon: LucideIcon;
label: string;
value: string;
detail?: string;
index?: number;
}) {
return (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04, duration: 0.35 }}
>
<Card className="h-full p-4 sm:p-5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-accent" />

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { Droplets, Gauge, Heart, Wind } from "lucide-react";
import { useWeatherStore } from "@/lib/store";
import { formatHumidity, formatPressure, formatTemperature, formatWind, getWeatherMoodFromData } from "@/lib/weather-utils";
import {
formatHumidity,
formatPressure,
formatTemperature,
formatWind,
getWeatherMoodFromData,
} from "@/lib/weather-utils";
import type { SynopStation } from "@/types/imgw";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -23,24 +29,56 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const mood = getWeatherMoodFromData(station);
const compactWind = station.windSpeed === null ? "—" : formatWind(station.windSpeed, null, language, windSpeedUnit);
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">
<div className="flex items-start justify-between gap-2">
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<Link
href={`/station/${station.id}`}
onClick={() => selectStation(station.id)}
className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<p className="truncate text-sm font-semibold">{station.name}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language, temperatureUnit)}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">
{formatTemperature(station.temperature, language, temperatureUnit)}
</p>
</Link>
<div className="flex flex-col items-end gap-2">
<WeatherIcon mood={mood} className="size-9 text-accent" />
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}>
<Button
variant="ghost"
className="size-8 p-0"
aria-label={
favorite
? t("favorites.removeStation", { name: station.name })
: t("favorites.addStation", { name: station.name })
}
onClick={() => toggleFavorite(station.id)}
>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
</Button>
</div>
</div>
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span>
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span>
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>
<Link
href={`/station/${station.id}`}
onClick={() => selectStation(station.id)}
className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span className="flex items-center gap-1">
<Droplets className="size-3" />
{formatHumidity(station.humidity, language)}
</span>
<span className="flex items-center gap-1">
<Wind className="size-3" />
{compactWind}
</span>
<span className="flex items-center gap-1">
<Gauge className="size-3" />
{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}
</span>
</Link>
</Card>
</motion.div>

View File

@@ -27,7 +27,13 @@ export function StationDetailPage({ id }: { id: string }) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/" className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><ArrowLeft className="size-4" />{t("station.all")}</Link>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<ArrowLeft className="size-4" />
{t("station.all")}
</Link>
<Button variant="glass" onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")}
@@ -43,12 +49,21 @@ export function StationDetailPage({ id }: { id: string }) {
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} />
<Card className="p-5">
<div className="section-kicker flex items-center gap-2"><ShieldCheck className="size-5" /><p>{t("station.quality")}</p></div>
<div className="section-kicker flex items-center gap-2">
<ShieldCheck className="size-5" />
<p>{t("station.quality")}</p>
</div>
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2>
<p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<dl className="mt-6 space-y-3 text-sm">
<div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
<div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
<div>
<dt className="text-muted">{t("station.lastMeasurement")}</dt>
<dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd>
</div>
<div>
<dt className="text-muted">{t("station.source")}</dt>
<dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd>
</div>
</dl>
</Card>
</div>

View File

@@ -43,7 +43,13 @@ const lightningBolts = [
},
];
export function WeatherEffects({ precipitation10m, thunderstorm = false }: { precipitation10m?: number | null; thunderstorm?: boolean }) {
export function WeatherEffects({
precipitation10m,
thunderstorm = false,
}: {
precipitation10m?: number | null;
thunderstorm?: boolean;
}) {
const reduceMotion = useReducedMotion();
const isRaining = (precipitation10m ?? 0) > 0;
@@ -55,7 +61,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span
key={`rain-${index}`}
initial={{ y: "-18vh", opacity: 0 }}
animate={reduceMotion ? { y: `${(index % 8) * 14}vh`, opacity: 0.28 } : { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }}
animate={
reduceMotion
? { y: `${(index % 8) * 14}vh`, opacity: 0.28 }
: { y: ["-18vh", "118vh"], opacity: [0, drop.opacity, 0] }
}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className={`absolute -top-12 w-0.5 rotate-[10deg] rounded-full bg-foreground/70 shadow-[0_0_8px_hsl(var(--foreground)/0.18)] ${drop.lengthClass}`}
style={{ left: drop.left }}
@@ -65,7 +75,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
<motion.span
key={`mist-${index}`}
initial={{ y: "-10vh", opacity: 0 }}
animate={reduceMotion ? { y: `${(index % 6) * 18}vh`, opacity: 0.16 } : { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }}
animate={
reduceMotion
? { y: `${(index % 6) * 18}vh`, opacity: 0.16 }
: { y: ["-10vh", "112vh"], opacity: [0, 0.26, 0] }
}
transition={{ duration: drop.duration, delay: drop.delay, repeat: Infinity, ease: "linear" }}
className="absolute -top-8 h-7 w-px rotate-[10deg] rounded-full bg-accent/55"
style={{ left: drop.left }}
@@ -80,7 +94,11 @@ export function WeatherEffects({ precipitation10m, thunderstorm = false }: { pre
key={`lightning-${index}`}
viewBox="0 0 96 176"
preserveAspectRatio="xMidYMid meet"
animate={reduceMotion ? { opacity: bolt.opacity * 0.22 } : { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }}
animate={
reduceMotion
? { opacity: bolt.opacity * 0.22 }
: { opacity: [0, 0, bolt.opacity, bolt.opacity * 0.08, 0], scaleY: [0.98, 0.98, 1.02, 1, 0.99] }
}
transition={{ duration: 3.6, repeat: Infinity, repeatDelay: 2.4, delay: bolt.delay }}
className={`absolute text-foreground dark:text-white ${bolt.className}`}
>

View File

@@ -31,7 +31,21 @@ 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 | 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 | null;
}) {
const { language, t } = useI18n();
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
@@ -40,35 +54,59 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
const hasGlobalModel = currentWeather?.coverage === "global-model";
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
const hasDistantFallback = !hasGlobalModel && !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm !== null && distanceKm >= 30;
const displayedStation = currentWeather ? {
...station,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall,
} : station;
const hasDistantFallback =
!hasGlobalModel &&
!hasFullHybridAnalysis &&
!currentWeatherLoading &&
distanceKm !== undefined &&
distanceKm !== null &&
distanceKm >= 30;
const displayedStation = currentWeather
? {
...station,
measuredAt: hasFullHybridAnalysis || hasGlobalModel ? currentWeather.measuredAt : station.measuredAt,
temperature: currentWeather.temperature ?? station.temperature,
windSpeed: currentWeather.windSpeed ?? station.windSpeed,
windDirection: currentWeather.windDirection ?? station.windDirection,
humidity: currentWeather.humidity ?? station.humidity,
pressure: currentWeather.pressure ?? station.pressure,
rainfall: currentWeather.precipitation10m ?? station.rainfall,
}
: station;
const mood = getWeatherMoodFromData(displayedStation);
const feelsLike = currentWeather?.feelsLike ?? calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
const feelsLike =
currentWeather?.feelsLike ??
calculateFeelsLike(displayedStation.temperature, displayedStation.humidity, displayedStation.windSpeed);
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: hasGlobalModel ? t("weather.currentPrecipitation") : currentWeather?.precipitation10m !== null && currentWeather?.precipitation10m !== undefined ? t("weather.rainfall10m") : t("weather.rainfallTotal"), value: formatRainfall(displayedStation.rainfall, language) },
{
icon: Wind,
label: t("weather.wind"),
value: formatWind(displayedStation.windSpeed, null, language, windSpeedUnit),
},
{
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"
? 0.1
: devEffectOverride === "none"
? 0
: currentWeather?.precipitation10m;
const effectThunderstorm = devEffectOverride === "thunderstorm"
? true
: devEffectOverride === "rain" || devEffectOverride === "none"
? false
: currentWeather?.condition === "thunderstorm";
const effectPrecipitation10m =
devEffectOverride === "rain" || devEffectOverride === "thunderstorm"
? 0.1
: devEffectOverride === "none"
? 0
: currentWeather?.precipitation10m;
const effectThunderstorm =
devEffectOverride === "thunderstorm"
? true
: devEffectOverride === "rain" || devEffectOverride === "none"
? false
: currentWeather?.condition === "thunderstorm";
const weatherDescription = getWeatherDescription(
displayedStation,
language,
@@ -97,22 +135,37 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
<div className="relative z-10">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted"><MapPin className="size-4" />{displayedLocationName}</span>
<span className="flex items-center gap-1.5 text-sm font-medium text-muted">
<MapPin className="size-4" />
{displayedLocationName}
</span>
</div>
<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
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName
? t("location.heroStationFallbackWithDistance", { station: station.name, distance: distanceKm ?? 0 })
: t("location.heroStationFallback", { station: station.name })}</p>
{hasFullHybridAnalysis && locationName && <p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>}
{hasDistantFallback && <p className="flex items-start gap-1.5 text-warning"><AlertTriangle className="mt-0.5 size-3.5 shrink-0" />{t("location.heroDistantFallback")}</p>}
<p>
{currentWeatherLoading
? t("location.heroHybridLoading", { station: station.name })
: hasGlobalModel
? t("location.heroGlobalModelSource", { location: displayedLocationName })
: hasFullHybridAnalysis
? t("location.heroHybridSource", { location: displayedLocationName })
: hasPartialHybridAnalysis
? t("location.heroHybridPartial", { station: station.name, distance: distanceKm ?? 0 })
: locationName
? t("location.heroStationFallbackWithDistance", {
station: station.name,
distance: distanceKm ?? 0,
})
: t("location.heroStationFallback", { station: station.name })}
</p>
{hasFullHybridAnalysis && locationName && (
<p>{t("location.heroNearestStation", { station: station.name, distance: distanceKm ?? 0 })}</p>
)}
{hasDistantFallback && (
<p className="flex items-start gap-1.5 text-warning">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
{t("location.heroDistantFallback")}
</p>
)}
</div>
</div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
@@ -121,14 +174,25 @@ 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)} · {hasGlobalModel ? t("weather.modelUpdate") : 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" />
<WeatherIcon
mood={mood}
condition={currentWeather?.condition}
className="mb-4 size-20 text-accent sm:size-28"
/>
</div>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-card border border-border/60 bg-surface-muted p-3.5">
<div className="flex items-center gap-2 text-xs text-muted"><Icon className="size-3.5 text-accent" />{label}</div>
<div className="flex items-center gap-2 text-xs text-muted">
<Icon className="size-3.5 text-accent" />
{label}
</div>
<p className="mt-2 text-base font-semibold">{value}</p>
</div>
))}

View File

@@ -2,14 +2,29 @@ import { Cloud, CloudLightning, CloudRain, CloudSun, MoonStar, Snowflake, Thermo
import type { WeatherMood } from "@/types/imgw";
import type { CurrentWeatherCondition } from "@/types/imgw-current";
export function WeatherIcon({ mood, condition, className = "" }: { mood: WeatherMood; condition?: CurrentWeatherCondition; className?: string }) {
const Icon = condition === "thunderstorm" ? CloudLightning : condition === "rain" ? CloudRain : condition === "snow" ? Snowflake : {
warm: ThermometerSun,
cloudy: Cloud,
wind: Wind,
cold: Snowflake,
night: MoonStar,
mild: CloudSun,
}[mood];
export function WeatherIcon({
mood,
condition,
className = "",
}: {
mood: WeatherMood;
condition?: CurrentWeatherCondition;
className?: string;
}) {
const Icon =
condition === "thunderstorm"
? CloudLightning
: condition === "rain"
? CloudRain
: condition === "snow"
? Snowflake
: {
warm: ThermometerSun,
cloudy: Cloud,
wind: Wind,
cold: Snowflake,
night: MoonStar,
mild: CloudSun,
}[mood];
return <Icon className={className} strokeWidth={1.35} />;
}