feat: add Open-Meteo weather forecast

This commit is contained in:
zv
2026-06-01 19:57:11 +02:00
parent 1c8b57b571
commit fc14b23001
12 changed files with 380 additions and 7 deletions

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. Aplikacja prezentuje bieżące odczyty synoptyczne, 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.
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.
@@ -38,9 +38,9 @@ npm run build
npm run start npm run start
``` ```
## Dane IMGW ## Źródła danych
Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW: Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop` - dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}` - pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
@@ -50,20 +50,23 @@ Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW:
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/` - dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product` - lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
Do wyszukiwania nazw miejscowości, bez pobierania danych pogodowych, używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić geokoder własnym dostawcą. Prognoza godzinowa i 7-dniowa pochodzi z Open-Meteo Forecast API: `https://api.open-meteo.com/v1/forecast`. Jest prezentowana oddzielnie od bieżących pomiarów IMGW i podpisana w interfejsie jako prognoza modelowa.
Przeglądarka pobiera dane przez whitelistowane proxy w `app/api/imgw/[...path]/route.ts`. Pozwala to ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`.
## Ograniczenia API ## Ograniczenia API
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`. Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. Dlatego prognoza pochodzi z Open-Meteo i jest wyraźnie oddzielona od pomiarów IMGW. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące. Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
## Struktura projektu ## Struktura projektu
```text ```text
app/ routing, layout, proxy IMGW, offline fallback app/ routing, layout, proxy danych, offline fallback
components/forecast/ prognoza godzinowa i dzienna Open-Meteo
components/dashboard dashboard aplikacji components/dashboard dashboard aplikacji
components/weather/ hero, stacje, metryki i szczegóły components/weather/ hero, stacje, metryki i szczegóły
components/warnings/ alerty meteo i hydro components/warnings/ alerty meteo i hydro

39
app/api/forecast/route.ts Normal file
View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
function parseCoordinate(value: string | null, min: number, max: number) {
if (!value?.trim()) return null;
const coordinate = Number(value);
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
if (latitude === null || longitude === null) {
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
}
const params = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
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",
});
try {
const response = await fetch(`${FORECAST_URL}?${params}`, { next: { revalidate: 900 } });
if (!response.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
return NextResponse.json(await response.json(), {
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
});
} catch {
return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
}
}

View File

@@ -11,6 +11,8 @@ import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
import { ErrorState } from "@/components/states/error-state"; import { ErrorState } from "@/components/states/error-state";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations"; import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
import { ForecastPanel } from "@/components/forecast/forecast-panel";
import { locateSynopStations } from "@/lib/location-utils";
export function DashboardPage() { export function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
@@ -24,11 +26,17 @@ export function DashboardPage() {
?? stations.find((station) => station.name === DEFAULT_STATION_NAME) ?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
?? stations[0]; ?? stations[0];
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null; 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 ( return (
<div className="space-y-10"> <div className="space-y-10">
<LocationSearch stations={stations} positions={positions} /> <LocationSearch stations={stations} positions={positions} />
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} /> <WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName} />
<FavoritesSection stations={stations} /> <FavoritesSection stations={stations} />
<FeaturedStationsSection stations={stations} /> <FeaturedStationsSection stations={stations} />
</div> </div>

View 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} />;
}

View 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>
);
}

16
hooks/use-forecast.ts Normal file
View File

@@ -0,0 +1,16 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchForecast } from "@/lib/forecast-api";
import { QUERY_GC_TIME } from "@/lib/constants";
export function useForecast(latitude?: number, longitude?: number) {
return useQuery({
queryKey: ["forecast", latitude, longitude],
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal),
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
staleTime: 15 * 60 * 1000,
gcTime: QUERY_GC_TIME,
retry: 2,
});
}

68
lib/forecast-api.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { DailyForecast, HourlyForecast, RawForecastSeries, RawWeatherForecast, WeatherForecast } from "@/types/forecast";
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
const value = asArray(series[key])[index];
return typeof value === "string" && value ? value : null;
}
function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
const value = asArray(series[key])[index];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeHourlyForecast(series: RawForecastSeries = {}): HourlyForecast[] {
return asArray(series.time).flatMap((_, index) => {
const time = readString(series, "time", index);
if (!time) return [];
return [{
time,
temperature: readNumber(series, "temperature_2m", index),
feelsLike: readNumber(series, "apparent_temperature", index),
precipitationProbability: readNumber(series, "precipitation_probability", index),
precipitation: readNumber(series, "precipitation", index),
weatherCode: readNumber(series, "weather_code", index),
windSpeed: readNumber(series, "wind_speed_10m", index),
}];
});
}
function normalizeDailyForecast(series: RawForecastSeries = {}): DailyForecast[] {
return asArray(series.time).flatMap((_, index) => {
const date = readString(series, "time", index);
if (!date) return [];
return [{
date,
temperatureMax: readNumber(series, "temperature_2m_max", index),
temperatureMin: readNumber(series, "temperature_2m_min", index),
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
precipitation: readNumber(series, "precipitation_sum", index),
weatherCode: readNumber(series, "weather_code", index),
sunrise: readString(series, "sunrise", index),
sunset: readString(series, "sunset", index),
}];
});
}
function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
const latitude = Number(raw.latitude);
const longitude = Number(raw.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
return {
latitude,
longitude,
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
hourly: normalizeHourlyForecast(raw.hourly),
daily: normalizeDailyForecast(raw.daily),
};
}
export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
const response = await fetch(`/api/forecast?${params}`, { signal });
if (!response.ok) throw new Error("Unable to load forecast.");
return normalizeForecast(await response.json() as RawWeatherForecast);
}

28
lib/forecast-utils.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { Language, TranslationKey } from "@/lib/i18n";
import { translate } from "@/lib/i18n";
export function getForecastConditionKey(code: number | null): TranslationKey {
if (code === 0) return "forecast.condition.clear";
if (code === 1 || code === 2) return "forecast.condition.partlyCloudy";
if (code === 3) return "forecast.condition.cloudy";
if (code === 45 || code === 48) return "forecast.condition.fog";
if (code !== null && code >= 51 && code <= 57) return "forecast.condition.drizzle";
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))) return "forecast.condition.rain";
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)) return "forecast.condition.snow";
if (code !== null && code >= 95) return "forecast.condition.thunderstorm";
return "forecast.condition.unknown";
}
export function getForecastCondition(code: number | null, language: Language) {
return translate(language, getForecastConditionKey(code));
}
export function formatForecastTemperature(value: number | null, language: Language) {
if (value === null) return "—";
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 0 }).format(value)}°`;
}
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`;
}

View File

@@ -66,6 +66,25 @@ const translations = {
"weather.windDirectionDetail": "Kierunek napływu wiatru", "weather.windDirectionDetail": "Kierunek napływu wiatru",
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW", "weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
"weather.temperatureDetail": "Temperatura powietrza", "weather.temperatureDetail": "Temperatura powietrza",
"forecast.label": "Prognoza modelowa",
"forecast.title": "Najbliższe godziny i dni",
"forecast.description": "Prognoza dla {location}. Bieżący pomiar powyżej pochodzi ze stacji IMGW, a poniższe wartości są prognozą modelową.",
"forecast.hourly": "Najbliższe 24 godziny",
"forecast.daily": "Prognoza 7-dniowa",
"forecast.today": "Dzisiaj",
"forecast.source": "Źródło prognozy:",
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
"forecast.emptyTitle": "Brak prognozy",
"forecast.emptyDescription": "Open-Meteo nie zwróciło teraz kompletnej prognozy dla tej lokalizacji.",
"forecast.condition.clear": "Bezchmurnie",
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
"forecast.condition.cloudy": "Pochmurno",
"forecast.condition.fog": "Mgła",
"forecast.condition.drizzle": "Mżawka",
"forecast.condition.rain": "Opady deszczu",
"forecast.condition.snow": "Opady śniegu",
"forecast.condition.thunderstorm": "Burza",
"forecast.condition.unknown": "Brak opisu",
"stations.emptyTitle": "Brak pasujących stacji", "stations.emptyTitle": "Brak pasujących stacji",
"station.all": "Wszystkie stacje", "station.all": "Wszystkie stacje",
"station.label": "Stacja {name}", "station.label": "Stacja {name}",
@@ -176,6 +195,25 @@ const translations = {
"weather.windDirectionDetail": "Direction the wind is coming from", "weather.windDirectionDetail": "Direction the wind is coming from",
"weather.rainfallDetail": "Total rainfall from the IMGW reading", "weather.rainfallDetail": "Total rainfall from the IMGW reading",
"weather.temperatureDetail": "Air temperature", "weather.temperatureDetail": "Air temperature",
"forecast.label": "Model forecast",
"forecast.title": "Upcoming hours and days",
"forecast.description": "Forecast for {location}. The current reading above comes from an IMGW station. The values below are a model forecast.",
"forecast.hourly": "Next 24 hours",
"forecast.daily": "7-day forecast",
"forecast.today": "Today",
"forecast.source": "Forecast source:",
"forecast.error": "Unable to load the Open-Meteo forecast.",
"forecast.emptyTitle": "Forecast unavailable",
"forecast.emptyDescription": "Open-Meteo did not return a complete forecast for this location.",
"forecast.condition.clear": "Clear sky",
"forecast.condition.partlyCloudy": "Partly cloudy",
"forecast.condition.cloudy": "Cloudy",
"forecast.condition.fog": "Fog",
"forecast.condition.drizzle": "Drizzle",
"forecast.condition.rain": "Rain",
"forecast.condition.snow": "Snow",
"forecast.condition.thunderstorm": "Thunderstorm",
"forecast.condition.unknown": "Description unavailable",
"stations.emptyTitle": "No matching stations", "stations.emptyTitle": "No matching stations",
"station.all": "All stations", "station.all": "All stations",
"station.label": "Station {name}", "station.label": "Station {name}",

View File

@@ -59,6 +59,8 @@ export function findNearestSynopStation(location: LocationSearchResult, stations
return { return {
name: location.name, name: location.name,
province: location.province, province: location.province,
latitude: location.latitude,
longitude: location.longitude,
stationId: nearest.station.id, stationId: nearest.station.id,
stationName: nearest.station.name, stationName: nearest.station.name,
distanceKm: Math.round(nearest.distanceKm), distanceKm: Math.round(nearest.distanceKm),

52
types/forecast.ts Normal file
View File

@@ -0,0 +1,52 @@
export interface RawForecastSeries {
time?: unknown;
temperature_2m?: unknown;
apparent_temperature?: unknown;
precipitation_probability?: unknown;
precipitation?: unknown;
weather_code?: unknown;
wind_speed_10m?: unknown;
temperature_2m_max?: unknown;
temperature_2m_min?: unknown;
precipitation_probability_max?: unknown;
precipitation_sum?: unknown;
sunrise?: unknown;
sunset?: unknown;
}
export interface RawWeatherForecast {
latitude?: unknown;
longitude?: unknown;
timezone?: unknown;
hourly?: RawForecastSeries;
daily?: RawForecastSeries;
}
export interface HourlyForecast {
time: string;
temperature: number | null;
feelsLike: number | null;
precipitationProbability: number | null;
precipitation: number | null;
weatherCode: number | null;
windSpeed: number | null;
}
export interface DailyForecast {
date: string;
temperatureMax: number | null;
temperatureMin: number | null;
precipitationProbability: number | null;
precipitation: number | null;
weatherCode: number | null;
sunrise: string | null;
sunset: string | null;
}
export interface WeatherForecast {
latitude: number;
longitude: number;
timezone: string;
hourly: HourlyForecast[];
daily: DailyForecast[];
}

View File

@@ -10,6 +10,8 @@ export interface LocationSearchResult {
export interface SelectedLocation { export interface SelectedLocation {
name: string; name: string;
province: string | null; province: string | null;
latitude: number;
longitude: number;
stationId: string; stationId: string;
stationName: string; stationName: string;
distanceKm: number; distanceKm: number;