diff --git a/AGENTS.md b/AGENTS.md index df9b355..fd7fb22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +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. - 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. +- 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. - `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. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. @@ -44,6 +45,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - Teksty interfejsu dodawaj równolegle po polsku i angielsku w `lib/i18n.tsx`. Nazw stacji i treści IMGW nie tłumacz automatycznie. - Trwałe preferencje użytkownika zapisuj przez istniejący store Zustand w `lib/store.ts` lub istniejące klucze `localStorage`. - Zachowuj mobile-first UI, dostępność klawiatury, `aria-label`, focus states oraz spokojny styl glassmorphism. +- Pełnoekranowe warstwy interaktywne zamykaj przyciskiem i klawiszem `Escape`, blokuj przewijanie tła oraz przywracaj fokus po zamknięciu. - Nie edytuj ręcznie `next-env.d.ts`; plik jest generowany przez Next.js. Obsługa błędów: diff --git a/README.md b/README.md index a9813ab..a9838d1 100644 --- a/README.md +++ b/README.md @@ -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. +`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. 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. @@ -73,6 +73,7 @@ Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs po ```text app/ routing, layout, proxy danych, offline fallback components/forecast/ prognoza godzinowa i dzienna Open-Meteo +components/charts/ wykresy odczytów i szczegółów prognozy components/dashboard dashboard aplikacji components/weather/ hero, stacje, metryki i szczegóły components/warnings/ alerty meteo i hydro diff --git a/app/api/forecast/route.ts b/app/api/forecast/route.ts index 0a03a0d..a9fbfff 100644 --- a/app/api/forecast/route.ts +++ b/app/api/forecast/route.ts @@ -22,7 +22,6 @@ export async function GET(request: Request) { hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m", daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset", timezone: "Europe/Warsaw", - forecast_hours: "24", forecast_days: "7", wind_speed_unit: "ms", }); diff --git a/app/globals.css b/app/globals.css index 6cb35a2..6e0cbbd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -65,6 +65,7 @@ select { .weather-scrollbar::-webkit-scrollbar { height: 0.65rem; + width: 0.65rem; } .weather-scrollbar::-webkit-scrollbar-track { diff --git a/components/charts/day-forecast-charts.tsx b/components/charts/day-forecast-charts.tsx new file mode 100644 index 0000000..83c0b49 --- /dev/null +++ b/components/charts/day-forecast-charts.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { Card } from "@/components/ui/card"; +import { useI18n } from "@/lib/i18n"; +import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils"; +import type { HourlyForecast } from "@/types/forecast"; + +function formatHour(value: string) { + return value.slice(11, 16); +} + +export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) { + const { language, t } = useI18n(); + const rows = hours.map((hour) => ({ + time: formatHour(hour.time), + temperature: hour.temperature, + feelsLike: hour.feelsLike, + precipitation: hour.precipitation, + precipitationProbability: hour.precipitationProbability, + })); + + return ( +
+ +

{t("forecast.temperatureChart")}

+

{t("forecast.temperatureChartDescription")}

+
+ + + + + + [formatForecastTemperature(typeof value === "number" ? value : null, language)]} + /> + + + + + +
+
+ + +

{t("forecast.rainfallChart")}

+

{t("forecast.rainfallChartDescription")}

+
+ + + + + + + [ + name === t("forecast.precipitation") + ? formatForecastRainfall(typeof value === "number" ? value : null, language) + : `${typeof value === "number" ? value : "—"}%`, + name, + ]} + /> + + + + + +
+
+
+ ); +} diff --git a/components/forecast/day-forecast-modal.tsx b/components/forecast/day-forecast-modal.tsx new file mode 100644 index 0000000..98bf396 --- /dev/null +++ b/components/forecast/day-forecast-modal.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CloudSun, Droplets, ExternalLink, Sunrise, Sunset, Wind, X } from "lucide-react"; +import { DayForecastCharts } from "@/components/charts/day-forecast-charts"; +import { ForecastIcon } from "@/components/forecast/forecast-icon"; +import { Card } from "@/components/ui/card"; +import { useI18n } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; +import { + formatForecastRainfall, + formatForecastTemperature, + formatForecastWind, + getForecastCondition, + isForecastHourPast, +} from "@/lib/forecast-utils"; +import type { DailyForecast, HourlyForecast } from "@/types/forecast"; + +function formatHour(value: string | null) { + if (!value) return "—"; + return value.slice(11, 16); +} + +function getMaximumWind(hours: HourlyForecast[]) { + return hours.reduce((maximum, hour) => { + if (hour.windSpeed === null) return maximum; + return maximum === null ? hour.windSpeed : Math.max(maximum, hour.windSpeed); + }, null); +} + +function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) { + return ( +
+ +

{label}

+

{value}

+
+ ); +} + +export function DayForecastModal({ + day, + hours, + locationName, + onClose, +}: { + day: DailyForecast | null; + hours: HourlyForecast[]; + locationName: string; + onClose: () => void; +}) { + const { language, locale, t } = useI18n(); + const closeButtonRef = useRef(null); + const maximumWind = useMemo(() => getMaximumWind(hours), [hours]); + + useEffect(() => { + if (!day) return; + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeButtonRef.current?.focus(); + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + previouslyFocused?.focus(); + }; + }, [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`)) + : ""; + + return ( + + {day ? ( + + event.stopPropagation()} + > +
+
+
+

+ + {t("forecast.dayDetails")} +

+

{locationName}

+

{formattedDate}

+
+ +
+ + +
+
+

{getForecastCondition(day.weatherCode, language)}

+
+ +

{formatForecastTemperature(day.temperatureMax, language)}

+

{formatForecastTemperature(day.temperatureMin, language)}

+
+
+
+ + + + +
+
+
+ + +

{t("forecast.hourlyForDay")}

+
+
    + {hours.map((hour) => { + const isPast = isForecastHourPast(hour.time); + return ( +
  • +

    {formatHour(hour.time)}

    + +

    {formatForecastTemperature(hour.temperature, language)}

    +

    + + {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} +

    +
  • + ); + })} +
+
+
+ + + +

+ {t("forecast.source")}{" "} + + Open-Meteo + +

+
+
+
+ ) : null} +
+ ); +} diff --git a/components/forecast/forecast-panel.tsx b/components/forecast/forecast-panel.tsx index 36651e5..e48da4b 100644 --- a/components/forecast/forecast-panel.tsx +++ b/components/forecast/forecast-panel.tsx @@ -1,7 +1,9 @@ "use client"; +import { useCallback, useState } from "react"; import { motion } from "framer-motion"; -import { CalendarDays, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react"; +import { CalendarDays, ChevronRight, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react"; +import { DayForecastModal } from "@/components/forecast/day-forecast-modal"; import { ForecastIcon } from "@/components/forecast/forecast-icon"; import { LoadingSkeleton } from "@/components/states/loading-skeleton"; import { EmptyState } from "@/components/states/empty-state"; @@ -9,7 +11,13 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useForecast } from "@/hooks/use-forecast"; import { useI18n } from "@/lib/i18n"; -import { formatForecastRainfall, formatForecastTemperature, getForecastCondition } from "@/lib/forecast-utils"; +import { + formatForecastRainfall, + formatForecastTemperature, + getForecastCondition, + getHourlyForecastForDay, + getUpcomingHourlyForecast, +} from "@/lib/forecast-utils"; import type { DailyForecast } from "@/types/forecast"; function formatHour(value: string) { @@ -21,22 +29,32 @@ 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 DailyForecastRow({ day, index }: { day: DailyForecast; index: number }) { +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); return ( -

{formatDay(day.date, locale, t("forecast.today"), index)}

-
- - {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} -
- {day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`} -

{formatForecastTemperature(day.temperatureMax, language)}{formatForecastTemperature(day.temperatureMin, language)}

+ onSelect(day)} + > +

{label}

+
+ + {getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)} +
+ {day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`} +

{formatForecastTemperature(day.temperatureMax, language)}{formatForecastTemperature(day.temperatureMin, language)}

+ +
); } @@ -44,6 +62,10 @@ function DailyForecastRow({ day, index }: { day: DailyForecast; index: number }) export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) { const { language, t } = useI18n(); const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude); + const [selectedDay, setSelectedDay] = useState(null); + const closeDayDetails = useCallback(() => setSelectedDay(null), []); + const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : []; + const selectedDayHours = forecast && selectedDay ? getHourlyForecastForDay(forecast.hourly, selectedDay.date) : []; return (
@@ -71,7 +93,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?

{t("forecast.hourly")}

    - {forecast.hourly.map((hour, index) => ( + {upcomingHours.map((hour, index) => (

    {t("forecast.daily")}

    -
      {forecast.daily.map((day, index) => )}
    +
      {forecast.daily.map((day, index) => )}
)} @@ -99,6 +121,8 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?

{t("forecast.source")} Open-Meteo

+ +
); } diff --git a/lib/forecast-utils.ts b/lib/forecast-utils.ts index d123a3c..d7b0f2a 100644 --- a/lib/forecast-utils.ts +++ b/lib/forecast-utils.ts @@ -1,5 +1,6 @@ import type { Language, TranslationKey } from "@/lib/i18n"; import { translate } from "@/lib/i18n"; +import type { HourlyForecast } from "@/types/forecast"; export function getForecastConditionKey(code: number | null): TranslationKey { if (code === 0) return "forecast.condition.clear"; @@ -26,3 +27,38 @@ export function formatForecastRainfall(value: number | null, language: Language) if (value === null) return "—"; return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`; } + +export function formatForecastWind(value: number | null, language: Language) { + if (value === null) return "—"; + return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(value)} m/s`; +} + +function getWarsawForecastHour(date = new Date()) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Warsaw", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""; + + return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:00`; +} + +export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) { + const currentHour = getWarsawForecastHour(); + return hours.filter((hour) => hour.time >= currentHour).slice(0, limit); +} + +export function getHourlyForecastForDay(hours: HourlyForecast[], date: string) { + return hours.filter((hour) => hour.time.startsWith(`${date}T`)); +} + +export function isForecastHourPast(time: string) { + return time < getWarsawForecastHour(); +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 87716ad..f379ad6 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -85,6 +85,22 @@ const translations = { "forecast.hourly": "Najbliższe 24 godziny", "forecast.daily": "Prognoza 7-dniowa", "forecast.today": "Dzisiaj", + "forecast.openDayDetails": "Otwórz szczegółową prognozę dla: {day}", + "forecast.dayDetails": "Prognoza szczegółowa", + "forecast.closeDetails": "Zamknij prognozę szczegółową", + "forecast.hourlyForDay": "Przebieg godzinowy", + "forecast.temperatureChart": "Temperatura w ciągu dnia", + "forecast.temperatureChartDescription": "Temperatura powietrza i temperatura odczuwalna według modelu.", + "forecast.rainfallChart": "Opad w ciągu dnia", + "forecast.rainfallChartDescription": "Przewidywana suma opadu oraz prawdopodobieństwo opadu.", + "forecast.temperature": "Temperatura", + "forecast.apparentTemperature": "Odczuwalna", + "forecast.precipitation": "Opad", + "forecast.precipitationProbability": "Prawdopodobieństwo", + "forecast.sunrise": "Wschód słońca", + "forecast.sunset": "Zachód słońca", + "forecast.maxWind": "Maks. wiatr", + "forecast.pastHour": "Miniona godzina", "forecast.source": "Źródło prognozy:", "forecast.error": "Nie udało się pobrać prognozy Open-Meteo.", "forecast.emptyTitle": "Brak prognozy", @@ -227,6 +243,22 @@ const translations = { "forecast.hourly": "Next 24 hours", "forecast.daily": "7-day forecast", "forecast.today": "Today", + "forecast.openDayDetails": "Open detailed forecast for: {day}", + "forecast.dayDetails": "Detailed forecast", + "forecast.closeDetails": "Close detailed forecast", + "forecast.hourlyForDay": "Hourly conditions", + "forecast.temperatureChart": "Temperature throughout the day", + "forecast.temperatureChartDescription": "Air temperature and apparent temperature according to the model.", + "forecast.rainfallChart": "Rainfall throughout the day", + "forecast.rainfallChartDescription": "Forecast rainfall total and precipitation probability.", + "forecast.temperature": "Temperature", + "forecast.apparentTemperature": "Feels like", + "forecast.precipitation": "Rainfall", + "forecast.precipitationProbability": "Probability", + "forecast.sunrise": "Sunrise", + "forecast.sunset": "Sunset", + "forecast.maxWind": "Max. wind", + "forecast.pastHour": "Past hour", "forecast.source": "Forecast source:", "forecast.error": "Unable to load the Open-Meteo forecast.", "forecast.emptyTitle": "Forecast unavailable",