feat: add Open-Meteo weather forecast
This commit is contained in:
@@ -11,6 +11,8 @@ import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||
import { ErrorState } from "@/components/states/error-state";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
||||
import { ForecastPanel } from "@/components/forecast/forecast-panel";
|
||||
import { locateSynopStations } from "@/lib/location-utils";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -24,11 +26,17 @@ export function DashboardPage() {
|
||||
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||
?? stations[0];
|
||||
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
|
||||
const stationPosition = locateSynopStations(stations, positions).find((station) => station.id === selectedStation.id);
|
||||
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation.name : selectedStation.name;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<LocationSearch stations={stations} positions={positions} />
|
||||
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName} />
|
||||
<FavoritesSection stations={stations} />
|
||||
<FeaturedStationsSection stations={stations} />
|
||||
</div>
|
||||
|
||||
13
components/forecast/forecast-icon.tsx
Normal file
13
components/forecast/forecast-icon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, CloudSun, Sun } from "lucide-react";
|
||||
|
||||
export function ForecastIcon({ code, className = "" }: { code: number | null; className?: string }) {
|
||||
const Icon = code === 0 ? Sun
|
||||
: code === 1 || code === 2 ? CloudSun
|
||||
: code === 45 || code === 48 ? CloudFog
|
||||
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle
|
||||
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain
|
||||
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow
|
||||
: code !== null && code >= 95 ? CloudLightning
|
||||
: Cloud;
|
||||
return <Icon className={className} strokeWidth={1.45} />;
|
||||
}
|
||||
104
components/forecast/forecast-panel.tsx
Normal file
104
components/forecast/forecast-panel.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarDays, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||
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, getForecastCondition } from "@/lib/forecast-utils";
|
||||
import type { DailyForecast } from "@/types/forecast";
|
||||
|
||||
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 DailyForecastRow({ day, index }: { day: DailyForecast; index: number }) {
|
||||
const { language, locale, t } = useI18n();
|
||||
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]"
|
||||
>
|
||||
<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.li>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><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-slate-600 dark:text-slate-300">{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-slate-600 dark:text-slate-300">{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="grid gap-3 lg:grid-cols-[1.35fr_1fr]">
|
||||
<Card className="overflow-hidden p-4 sm:p-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>
|
||||
<div className="-mx-4 mt-4 overflow-x-auto px-4 pb-1 sm:-mx-5 sm:px-5">
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{forecast.hourly.map((hour, index) => (
|
||||
<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-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-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
||||
<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>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user