style: fill desktop hourly forecast card

This commit is contained in:
zv
2026-06-02 15:46:20 +02:00
parent e8095ddcdb
commit fa8ca75dcd
3 changed files with 62 additions and 4 deletions

View File

@@ -2,7 +2,7 @@
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżące odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżące odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami. Dashboard pokazuje rozszerzony desktopowy podgląd godzin z podsumowaniem najbliższej doby, a także wykresy temperatury i opadu dla bieżącego dnia. Każdy dzień prognozy można także otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami.
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.

View File

@@ -2,7 +2,7 @@
import { useCallback, useState } from "react";
import { motion } from "framer-motion";
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, ExternalLink, RefreshCw, ThermometerSun, Wind } from "lucide-react";
import { CalendarDays, ChevronRight, Clock3, CloudRain, CloudSun, Droplets, ExternalLink, 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";
@@ -20,7 +20,7 @@ import {
getHourlyForecastForDay,
getUpcomingHourlyForecast,
} from "@/lib/forecast-utils";
import type { DailyForecast } from "@/types/forecast";
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
function formatHour(value: string) {
return value.slice(11, 16);
@@ -31,6 +31,55 @@ function formatDay(value: string, locale: string, todayLabel: string, index: num
return new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(new Date(`${value}T12:00:00Z`));
}
function getMaximum(values: Array<number | null>) {
const availableValues = values.filter((value): value is number => value !== null);
return availableValues.length ? Math.max(...availableValues) : null;
}
function getMinimum(values: Array<number | null>) {
const availableValues = values.filter((value): value is number => value !== null);
return availableValues.length ? Math.min(...availableValues) : null;
}
function getTotal(values: Array<number | null>) {
const availableValues = values.filter((value): value is number => value !== null);
return availableValues.length ? availableValues.reduce((total, value) => total + value, 0) : null;
}
function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5">
<Icon className="size-4 text-sky-700 dark:text-sky-300" />
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">{label}</p>
<p className="mt-1 text-sm font-semibold">{value}</p>
</div>
);
}
function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
const { language, t } = useI18n();
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
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)} / ${formatForecastTemperature(maximumTemperature, language)}`;
return (
<div className="mt-auto hidden border-t border-white/30 pt-4 dark:border-white/10 lg:block">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{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)} />
<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 }) {
const { language, locale, t } = useI18n();
const label = formatDay(day.date, locale, t("forecast.today"), index);
@@ -95,7 +144,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<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-sky-600 dark:text-sky-300" />{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:my-auto lg:pt-4">
<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) => (
<motion.li
@@ -128,6 +177,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
))}
</ul>
</div>
<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-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3>

View File

@@ -100,6 +100,10 @@ const translations = {
"forecast.sunrise": "Wschód słońca",
"forecast.sunset": "Zachód słońca",
"forecast.maxWind": "Maks. wiatr",
"forecast.nextHoursOverview": "Najbliższe 24 godziny w skrócie",
"forecast.temperatureRange": "Zakres temperatur",
"forecast.rainfallTotal": "Suma opadu",
"forecast.maxProbability": "Maks. szansa opadu",
"forecast.pastHour": "Miniona godzina",
"forecast.source": "Źródło prognozy:",
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
@@ -258,6 +262,10 @@ const translations = {
"forecast.sunrise": "Sunrise",
"forecast.sunset": "Sunset",
"forecast.maxWind": "Max. wind",
"forecast.nextHoursOverview": "Next 24 hours at a glance",
"forecast.temperatureRange": "Temperature range",
"forecast.rainfallTotal": "Rainfall total",
"forecast.maxProbability": "Max. rain chance",
"forecast.pastHour": "Past hour",
"forecast.source": "Forecast source:",
"forecast.error": "Unable to load the Open-Meteo forecast.",