feat: add interactive daily forecast details

This commit is contained in:
zv
2026-06-02 15:31:36 +02:00
parent 352287bc38
commit d089a71bef
9 changed files with 368 additions and 15 deletions

View File

@@ -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 (
<motion.li
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
className="grid grid-cols-[4.5rem_1fr_auto] items-center gap-2 border-t border-white/30 py-3 first:border-t-0 dark:border-white/10 sm:grid-cols-[5rem_1fr_5rem_auto]"
className="border-t border-white/30 first:border-t-0 dark:border-white/10"
>
<p className="text-sm font-semibold capitalize">{formatDay(day.date, locale, t("forecast.today"), index)}</p>
<div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
</div>
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
<motion.button
type="button"
whileTap={{ scale: 0.99 }}
aria-label={t("forecast.openDayDetails", { day: label })}
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-xl px-1 py-3 text-left transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:hover:bg-white/5 sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
onClick={() => onSelect(day)}
>
<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-sky-600 dark:text-sky-300" />
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
</div>
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
<ChevronRight className="hidden size-4 text-slate-400 sm:block" />
</motion.button>
</motion.li>
);
}
@@ -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<DailyForecast | null>(null);
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
const upcomingHours = forecast ? getUpcomingHourlyForecast(forecast.hourly) : [];
const selectedDayHours = forecast && selectedDay ? getHourlyForecastForDay(forecast.hourly, selectedDay.date) : [];
return (
<section className="space-y-3">
@@ -71,7 +93,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<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">
<ul className="flex min-w-max gap-2">
{forecast.hourly.map((hour, index) => (
{upcomingHours.map((hour, index) => (
<motion.li
key={hour.time}
initial={{ opacity: 0, y: 8 }}
@@ -91,7 +113,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
</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>
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} />)}</ul>
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
</Card>
</div>
)}
@@ -99,6 +121,8 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
{t("forecast.source")} <a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo <ExternalLink className="size-3" /></a>
</p>
<DayForecastModal day={selectedDay} hours={selectedDayHours} locationName={locationName} onClose={closeDayDetails} />
</section>
);
}