feat: show current-day forecast charts on dashboard

This commit is contained in:
zv
2026-06-02 15:36:22 +02:00
parent d089a71bef
commit 09fa2667d8
3 changed files with 34 additions and 29 deletions

View File

@@ -37,7 +37,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji. - Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`. - Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. - Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
- Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty. - Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu. - `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.

View File

@@ -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. Każdy dzień prognozy można otworzyć w animowanym widoku szczegółowym z przebiegiem godzinowym oraz wykresami temperatury i opadu. `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 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.

View File

@@ -3,6 +3,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, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react"; import { CalendarDays, ChevronRight, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react";
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";
import { LoadingSkeleton } from "@/components/states/loading-skeleton"; import { LoadingSkeleton } from "@/components/states/loading-skeleton";
@@ -65,6 +66,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null); const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
const closeDayDetails = useCallback(() => setSelectedDay(null), []); const closeDayDetails = useCallback(() => setSelectedDay(null), []);
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : []; const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
const todayHours = forecast?.daily[0] ? getHourlyForecastForDay(forecast.hourly, forecast.daily[0].date) : [];
const selectedDayHours = forecast && selectedDay ? getHourlyForecastForDay(forecast.hourly, selectedDay.date) : []; const selectedDayHours = forecast && selectedDay ? getHourlyForecastForDay(forecast.hourly, selectedDay.date) : [];
return ( return (
@@ -88,33 +90,36 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
) : !forecast.hourly.length || !forecast.daily.length ? ( ) : !forecast.hourly.length || !forecast.daily.length ? (
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} /> <EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
) : ( ) : (
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr]"> <div className="space-y-3">
<Card className="overflow-hidden p-4 sm:p-5"> <div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr]">
<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> <Card className="overflow-hidden p-4 sm:p-5">
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5"> <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>
<ul className="flex min-w-max gap-2"> <div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5">
{upcomingHours.map((hour, index) => ( <ul className="flex min-w-max gap-2">
<motion.li {upcomingHours.map((hour, index) => (
key={hour.time} <motion.li
initial={{ opacity: 0, y: 8 }} key={hour.time}
animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 8 }}
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }} animate={{ opacity: 1, y: 0 }}
className="w-[4.6rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5" transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
title={getForecastCondition(hour.weatherCode, language)} className="w-[4.6rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5"
> title={getForecastCondition(hour.weatherCode, language)}
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p> >
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" /> <p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p> <ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p> <p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
</motion.li> <p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
))} </motion.li>
</ul> ))}
</div> </ul>
</Card> </div>
<Card className="p-4 sm:p-5"> </Card>
<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> <Card className="p-4 sm:p-5">
<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-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3>
</Card> <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} />
</div> </div>
)} )}