style: fill desktop hourly forecast card
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
**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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
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 { DayForecastCharts } from "@/components/charts/day-forecast-charts";
|
||||||
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
import { DayForecastModal } from "@/components/forecast/day-forecast-modal";
|
||||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
getHourlyForecastForDay,
|
getHourlyForecastForDay,
|
||||||
getUpcomingHourlyForecast,
|
getUpcomingHourlyForecast,
|
||||||
} from "@/lib/forecast-utils";
|
} from "@/lib/forecast-utils";
|
||||||
import type { DailyForecast } from "@/types/forecast";
|
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
function formatHour(value: string) {
|
function formatHour(value: string) {
|
||||||
return value.slice(11, 16);
|
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`));
|
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 }) {
|
function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index: number; onSelect: (day: DailyForecast) => void }) {
|
||||||
const { language, locale, t } = useI18n();
|
const { language, locale, t } = useI18n();
|
||||||
const label = formatDay(day.date, locale, t("forecast.today"), index);
|
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">
|
<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">
|
<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>
|
<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">
|
<ul className="flex min-w-max gap-2">
|
||||||
{upcomingHours.map((hour, index) => (
|
{upcomingHours.map((hour, index) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
@@ -128,6 +177,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<HourlyForecastSummary hours={upcomingHours} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 sm:p-5">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ const translations = {
|
|||||||
"forecast.sunrise": "Wschód słońca",
|
"forecast.sunrise": "Wschód słońca",
|
||||||
"forecast.sunset": "Zachód słońca",
|
"forecast.sunset": "Zachód słońca",
|
||||||
"forecast.maxWind": "Maks. wiatr",
|
"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.pastHour": "Miniona godzina",
|
||||||
"forecast.source": "Źródło prognozy:",
|
"forecast.source": "Źródło prognozy:",
|
||||||
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
|
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
|
||||||
@@ -258,6 +262,10 @@ const translations = {
|
|||||||
"forecast.sunrise": "Sunrise",
|
"forecast.sunrise": "Sunrise",
|
||||||
"forecast.sunset": "Sunset",
|
"forecast.sunset": "Sunset",
|
||||||
"forecast.maxWind": "Max. wind",
|
"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.pastHour": "Past hour",
|
||||||
"forecast.source": "Forecast source:",
|
"forecast.source": "Forecast source:",
|
||||||
"forecast.error": "Unable to load the Open-Meteo forecast.",
|
"forecast.error": "Unable to load the Open-Meteo forecast.",
|
||||||
|
|||||||
Reference in New Issue
Block a user