306 lines
14 KiB
TypeScript
306 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { motion } from "framer-motion";
|
|
import {
|
|
CalendarDays,
|
|
ChevronRight,
|
|
Clock3,
|
|
CloudRain,
|
|
CloudSun,
|
|
Droplets,
|
|
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";
|
|
import { ForecastSources } from "@/components/forecast/forecast-sources";
|
|
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
|
import { EmptyState } from "@/components/states/empty-state";
|
|
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,
|
|
formatForecastWind,
|
|
getDailyForecastForHour,
|
|
getEffectiveHourlyWeatherCode,
|
|
getForecastCondition,
|
|
getHourlyForecastForDay,
|
|
getUpcomingHourlyForecast,
|
|
isForecastNight,
|
|
} from "@/lib/forecast-utils";
|
|
import { useWeatherStore } from "@/lib/store";
|
|
import type { DailyForecast, HourlyForecast } from "@/types/forecast";
|
|
import type { WeatherRegion } from "@/types/weather-region";
|
|
|
|
function formatHour(value: string) {
|
|
return value.slice(11, 16);
|
|
}
|
|
|
|
function formatDay(value: string, locale: string, todayLabel: string, index: number) {
|
|
if (index === 0) return todayLabel;
|
|
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-card border border-border/60 bg-surface-muted/55 p-3">
|
|
<Icon className="size-4 text-accent" />
|
|
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-muted">{label}</p>
|
|
<p className="mt-1 text-sm font-semibold">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
|
|
const { language, t } = useI18n();
|
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
|
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
|
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, temperatureUnit)} / ${formatForecastTemperature(maximumTemperature, language, temperatureUnit)}`;
|
|
|
|
return (
|
|
<div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">
|
|
{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, windSpeedUnit)}
|
|
/>
|
|
<HourlySummaryMetric
|
|
icon={CloudRain}
|
|
label={t("forecast.rainfallTotal")}
|
|
value={formatForecastRainfall(rainfallTotal, language, precipitationUnit)}
|
|
/>
|
|
<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 temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
|
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
|
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="border-t border-border/65 first:border-t-0"
|
|
>
|
|
<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-card px-1 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent 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-accent" />
|
|
<span className="truncate text-xs text-muted">
|
|
{getForecastCondition(day.weatherCode, language)} ·{" "}
|
|
{formatForecastRainfall(day.precipitation, language, precipitationUnit)}
|
|
</span>
|
|
</div>
|
|
<span className="hidden items-center gap-1 text-xs text-accent sm:flex">
|
|
<Droplets className="size-3" />
|
|
{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}
|
|
</span>
|
|
<p className="whitespace-nowrap text-sm">
|
|
<strong>{formatForecastTemperature(day.temperatureMax, language, temperatureUnit)}</strong>
|
|
<span className="ml-2 text-muted">
|
|
{formatForecastTemperature(day.temperatureMin, language, temperatureUnit)}
|
|
</span>
|
|
</p>
|
|
<ChevronRight className="hidden size-4 text-muted sm:block" />
|
|
</motion.button>
|
|
</motion.li>
|
|
);
|
|
}
|
|
|
|
export function ForecastPanel({
|
|
latitude,
|
|
longitude,
|
|
region = "PL",
|
|
locationName,
|
|
}: {
|
|
latitude?: number;
|
|
longitude?: number;
|
|
region?: WeatherRegion;
|
|
locationName: string;
|
|
}) {
|
|
const { language, t } = useI18n();
|
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
|
const windSpeedUnit = useWeatherStore((state) => state.windSpeedUnit);
|
|
const precipitationUnit = useWeatherStore((state) => state.precipitationUnit);
|
|
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude, region);
|
|
const [selectedDay, setSelectedDay] = useState<DailyForecast | null>(null);
|
|
const closeDayDetails = useCallback(() => setSelectedDay(null), []);
|
|
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) : [];
|
|
|
|
return (
|
|
<section className="space-y-3">
|
|
<div>
|
|
<p className="section-kicker flex items-center gap-2">
|
|
<CloudSun className="size-4" />
|
|
{t("forecast.label")}
|
|
</p>
|
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
|
|
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted">
|
|
{t("forecast.description", { location: locationName })}
|
|
</p>
|
|
</div>
|
|
|
|
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
|
|
<div className="grid gap-3 lg:grid-cols-[1.35fr_1fr]" aria-busy="true">
|
|
<LoadingSkeleton className="h-60" />
|
|
<LoadingSkeleton className="h-60" />
|
|
</div>
|
|
) : isError || !forecast ? (
|
|
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
|
|
<p className="text-sm text-muted">{t("forecast.error")}</p>
|
|
<Button variant="glass" className="mt-4" onClick={() => refetch()}>
|
|
<RefreshCw className="size-4" />
|
|
{t("common.retry")}
|
|
</Button>
|
|
</Card>
|
|
) : !forecast.hourly.length || !forecast.daily.length ? (
|
|
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
<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-accent" />
|
|
{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:mt-5">
|
|
<ul className="flex min-w-max gap-2">
|
|
{upcomingHours.map((hour, index) => {
|
|
const day = getDailyForecastForHour(forecast.daily, hour.time);
|
|
const weatherCode = getEffectiveHourlyWeatherCode(hour);
|
|
return (
|
|
<motion.li
|
|
key={hour.time}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
|
className="w-[4.6rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center lg:w-[5.5rem] lg:py-4"
|
|
title={getForecastCondition(weatherCode, language)}
|
|
>
|
|
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
|
|
<ForecastIcon
|
|
code={weatherCode}
|
|
isNight={isForecastNight(hour.time, day)}
|
|
className="mx-auto my-3 size-6 text-accent"
|
|
/>
|
|
<p className="text-lg font-semibold tracking-tight">
|
|
{formatForecastTemperature(hour.temperature, language, temperatureUnit)}
|
|
</p>
|
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
|
|
<Droplets className="size-3" />
|
|
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
|
</p>
|
|
<div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
|
|
<p
|
|
className="flex items-center justify-center gap-1"
|
|
title={t("forecast.apparentTemperature")}
|
|
>
|
|
<ThermometerSun className="size-3 text-accent" />
|
|
{formatForecastTemperature(hour.feelsLike, language, temperatureUnit)}
|
|
</p>
|
|
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
|
|
<Wind className="size-3 text-accent" />
|
|
{formatForecastWind(hour.windSpeed, language, windSpeedUnit)}
|
|
</p>
|
|
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
|
|
<CloudRain className="size-3 text-accent" />
|
|
{formatForecastRainfall(hour.precipitation, language, precipitationUnit)}
|
|
</p>
|
|
</div>
|
|
</motion.li>
|
|
);
|
|
})}
|
|
</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-accent" />
|
|
{t("forecast.daily")}
|
|
</h3>
|
|
<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>
|
|
)}
|
|
|
|
{forecast && <ForecastSources sources={forecast.sources} />}
|
|
|
|
<DayForecastModal
|
|
day={selectedDay}
|
|
hours={selectedDayHours}
|
|
locationName={locationName}
|
|
sources={forecast?.sources ?? []}
|
|
onClose={closeDayDetails}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|