feat: build production-ready wtr weather PWA
This commit is contained in:
12
lib/constants.ts
Normal file
12
lib/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const DEFAULT_STATION_NAME = "Warszawa";
|
||||
export const APP_NAME = "wtr.";
|
||||
export const APP_TAGLINE = "Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.";
|
||||
|
||||
export const QUERY_STALE_TIME = 5 * 60 * 1000;
|
||||
export const QUERY_GC_TIME = 60 * 60 * 1000;
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ href: "/", label: "Pogoda" },
|
||||
{ href: "/warnings", label: "Ostrzeżenia" },
|
||||
{ href: "/hydro", label: "Hydro" },
|
||||
] as const;
|
||||
57
lib/imgw-api.ts
Normal file
57
lib/imgw-api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
normalizeHydroStation,
|
||||
normalizeSynopStation,
|
||||
normalizeWarning,
|
||||
} from "@/lib/weather-utils";
|
||||
import type {
|
||||
HydroStation,
|
||||
RawHydroStation,
|
||||
RawSynopStation,
|
||||
RawWarning,
|
||||
SynopStation,
|
||||
WeatherWarning,
|
||||
WarningKind,
|
||||
} from "@/types/imgw";
|
||||
|
||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(`/api/imgw/${path}`, { signal });
|
||||
if (!response.ok) {
|
||||
const details = await response.text().catch(() => "");
|
||||
throw new Error(details || `IMGW API zwróciło status ${response.status}.`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function fetchSynopStations(signal?: AbortSignal): Promise<SynopStation[]> {
|
||||
const rows = await getJson<RawSynopStation[]>("synop", signal);
|
||||
return rows.map(normalizeSynopStation).filter((station): station is SynopStation => station !== null);
|
||||
}
|
||||
|
||||
export async function fetchSynopStation(id: string, signal?: AbortSignal): Promise<SynopStation> {
|
||||
const row = await getJson<RawSynopStation>(`synop/id/${encodeURIComponent(id)}`, signal);
|
||||
const station = normalizeSynopStation(row);
|
||||
if (!station) throw new Error("IMGW zwróciło niekompletne dane stacji.");
|
||||
return station;
|
||||
}
|
||||
|
||||
export async function fetchHydroStations(signal?: AbortSignal): Promise<HydroStation[]> {
|
||||
const rows = await getJson<RawHydroStation[]>("hydro", signal);
|
||||
return rows.map(normalizeHydroStation).filter((station): station is HydroStation => station !== null);
|
||||
}
|
||||
|
||||
async function fetchWarningsByKind(kind: WarningKind, signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||
const rows = await getJson<RawWarning[]>(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal);
|
||||
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];
|
||||
}
|
||||
|
||||
export async function fetchWarnings(signal?: AbortSignal): Promise<WeatherWarning[]> {
|
||||
const results = await Promise.allSettled([
|
||||
fetchWarningsByKind("meteo", signal),
|
||||
fetchWarningsByKind("hydro", signal),
|
||||
]);
|
||||
const warnings = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
||||
if (results.every((result) => result.status === "rejected")) {
|
||||
throw new Error("Nie udało się pobrać ostrzeżeń IMGW.");
|
||||
}
|
||||
return warnings.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""));
|
||||
}
|
||||
28
lib/store.ts
Normal file
28
lib/store.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface WeatherStore {
|
||||
favorites: string[];
|
||||
selectedStationId: string | null;
|
||||
toggleFavorite: (id: string) => void;
|
||||
selectStation: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useWeatherStore = create<WeatherStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
favorites: [],
|
||||
selectedStationId: null,
|
||||
toggleFavorite: (id) =>
|
||||
set((state) => ({
|
||||
favorites: state.favorites.includes(id)
|
||||
? state.favorites.filter((favoriteId) => favoriteId !== id)
|
||||
: [...state.favorites, id],
|
||||
})),
|
||||
selectStation: (id) => set({ selectedStationId: id }),
|
||||
}),
|
||||
{ name: "wtr:preferences" },
|
||||
),
|
||||
);
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
187
lib/weather-utils.ts
Normal file
187
lib/weather-utils.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
HydroStation,
|
||||
RawHydroStation,
|
||||
RawSynopStation,
|
||||
RawWarning,
|
||||
SynopStation,
|
||||
WeatherMood,
|
||||
WeatherWarning,
|
||||
WarningKind,
|
||||
} from "@/types/imgw";
|
||||
|
||||
const polishLocale = "pl-PL";
|
||||
|
||||
export function toNumber(value: unknown): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== "string" || value.trim() === "") return null;
|
||||
const parsed = Number(value.replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeDate(value?: string | null): string | null {
|
||||
if (!value?.trim()) return null;
|
||||
const isoCandidate = value.includes("T") ? value : value.replace(" ", "T");
|
||||
const date = new Date(isoCandidate);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function synopMeasuredAt(date?: string | null, hour?: string | null) {
|
||||
if (!date?.trim() || !hour?.trim()) return null;
|
||||
return normalizeDate(`${date}T${hour.padStart(2, "0")}:00:00Z`);
|
||||
}
|
||||
|
||||
export function normalizeSynopStation(raw: RawSynopStation): SynopStation | null {
|
||||
if (!raw.id_stacji?.trim() || !raw.stacja?.trim()) return null;
|
||||
return {
|
||||
id: raw.id_stacji,
|
||||
name: raw.stacja,
|
||||
measuredAt: synopMeasuredAt(raw.data_pomiaru, raw.godzina_pomiaru),
|
||||
temperature: toNumber(raw.temperatura),
|
||||
windSpeed: toNumber(raw.predkosc_wiatru),
|
||||
windDirection: toNumber(raw.kierunek_wiatru),
|
||||
humidity: toNumber(raw.wilgotnosc_wzgledna),
|
||||
rainfall: toNumber(raw.suma_opadu),
|
||||
pressure: toNumber(raw.cisnienie),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null {
|
||||
if (!raw.id_stacji?.trim() || !raw.stacja?.trim()) return null;
|
||||
return {
|
||||
id: raw.id_stacji,
|
||||
name: raw.stacja,
|
||||
river: raw.rzeka?.trim() || null,
|
||||
province: raw.wojewodztwo?.trim() || null,
|
||||
longitude: toNumber(raw.lon),
|
||||
latitude: toNumber(raw.lat),
|
||||
waterLevel: toNumber(raw.stan_wody),
|
||||
waterLevelMeasuredAt: normalizeDate(raw.stan_wody_data_pomiaru),
|
||||
waterTemperature: toNumber(raw.temperatura_wody),
|
||||
waterTemperatureMeasuredAt: normalizeDate(raw.temperatura_wody_data_pomiaru),
|
||||
flow: toNumber(raw.przeplyw),
|
||||
flowMeasuredAt: normalizeDate(raw.przeplyw_data),
|
||||
icePhenomenon: toNumber(raw.zjawisko_lodowe),
|
||||
overgrowthPhenomenon: toNumber(raw.zjawisko_zarastania),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
|
||||
const describedAreas = (raw.obszary ?? [])
|
||||
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
||||
.filter((area): area is string => Boolean(area));
|
||||
const areas = describedAreas.length ? describedAreas : (raw.teryt ?? []).map((code) => `TERYT ${code}`);
|
||||
const title = raw.zdarzenie?.trim() || raw.nazwa_zdarzenia?.trim() || (kind === "meteo" ? "Ostrzeżenie meteorologiczne" : "Ostrzeżenie hydrologiczne");
|
||||
return {
|
||||
id: `${kind}-${raw.id ?? raw.numer ?? index}-${raw.data_od ?? raw.obowiazuje_od ?? "unknown"}`,
|
||||
kind,
|
||||
level: toNumber(raw["stopień"] ?? raw.stopien),
|
||||
title,
|
||||
description: raw.przebieg?.trim() || raw.tresc?.trim() || null,
|
||||
comment: raw.komentarz?.trim() || null,
|
||||
validFrom: normalizeDate(raw.data_od ?? raw.obowiazuje_od),
|
||||
validTo: raw.data_do?.startsWith("9999-") ? null : normalizeDate(raw.data_do ?? raw.obowiazuje_do),
|
||||
publishedAt: normalizeDate(raw.opublikowano),
|
||||
probability: toNumber(raw.prawdopodobienstwo),
|
||||
areas,
|
||||
office: raw.biuro?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTemperature(value: number | null) {
|
||||
return value === null ? "Brak danych" : `${Math.round(value)}°`;
|
||||
}
|
||||
|
||||
export function formatPressure(value: number | null) {
|
||||
return value === null ? "Brak danych" : `${value.toFixed(1)} hPa`;
|
||||
}
|
||||
|
||||
export function formatHumidity(value: number | null) {
|
||||
return value === null ? "Brak danych" : `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
export function formatWind(speed: number | null, direction?: number | null) {
|
||||
if (speed === null) return "Brak danych";
|
||||
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
||||
return `${speed.toFixed(1)} m/s${directionLabel}`;
|
||||
}
|
||||
|
||||
export function formatRainfall(value: number | null) {
|
||||
return value === null ? "Brak danych" : `${value.toFixed(value < 1 ? 2 : 1)} mm`;
|
||||
}
|
||||
|
||||
export function formatWaterLevel(value: number | null) {
|
||||
return value === null ? "Brak danych" : `${Math.round(value)} cm`;
|
||||
}
|
||||
|
||||
export function formatFlow(value: number | null) {
|
||||
return value === null ? "Brak danych" : `${value.toFixed(2)} m³/s`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null, fallback = "Brak danych") {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return new Intl.DateTimeFormat(polishLocale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function calculateFeelsLike(temperature: number | null, humidity: number | null, windSpeed: number | null) {
|
||||
if (temperature === null) return null;
|
||||
if (temperature <= 10 && windSpeed !== null && windSpeed > 1.34) {
|
||||
const windKmh = windSpeed * 3.6;
|
||||
return 13.12 + 0.6215 * temperature - 11.37 * windKmh ** 0.16 + 0.3965 * temperature * windKmh ** 0.16;
|
||||
}
|
||||
if (temperature >= 27 && humidity !== null) {
|
||||
const c1 = -8.78469475556;
|
||||
const c2 = 1.61139411;
|
||||
const c3 = 2.33854883889;
|
||||
const c4 = -0.14611605;
|
||||
const c5 = -0.012308094;
|
||||
const c6 = -0.0164248277778;
|
||||
const c7 = 0.002211732;
|
||||
const c8 = 0.00072546;
|
||||
const c9 = -0.000003582;
|
||||
return c1 + c2 * temperature + c3 * humidity + c4 * temperature * humidity + c5 * temperature ** 2 +
|
||||
c6 * humidity ** 2 + c7 * temperature ** 2 * humidity + c8 * temperature * humidity ** 2 +
|
||||
c9 * temperature ** 2 * humidity ** 2;
|
||||
}
|
||||
return temperature;
|
||||
}
|
||||
|
||||
export function getWindDirection(degrees: number) {
|
||||
const labels = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return labels[Math.round((((degrees % 360) + 360) % 360) / 45) % 8];
|
||||
}
|
||||
|
||||
export function getWeatherMoodFromData(station: SynopStation, date = new Date()): WeatherMood {
|
||||
const hour = date.getHours();
|
||||
if (hour < 6 || hour >= 21) return "night";
|
||||
if ((station.rainfall ?? 0) >= 0.1) return "rain";
|
||||
if ((station.windSpeed ?? 0) >= 8) return "wind";
|
||||
if ((station.temperature ?? 15) <= 3) return "cold";
|
||||
if ((station.temperature ?? 15) >= 20) return "clear";
|
||||
return "mild";
|
||||
}
|
||||
|
||||
export function getWeatherDescription(station: SynopStation) {
|
||||
if ((station.rainfall ?? 0) >= 5) return "Wyraźne opady";
|
||||
if ((station.rainfall ?? 0) >= 0.1) return "Opady";
|
||||
if ((station.windSpeed ?? 0) >= 8) return "Silny wiatr";
|
||||
if ((station.humidity ?? 0) >= 90) return "Wilgotno";
|
||||
return "Spokojne warunki";
|
||||
}
|
||||
|
||||
export function moodGradient(mood: WeatherMood) {
|
||||
return {
|
||||
clear: "from-sky-500 via-blue-500 to-indigo-700",
|
||||
rain: "from-slate-500 via-slate-600 to-indigo-900",
|
||||
wind: "from-cyan-600 via-slate-500 to-blue-900",
|
||||
cold: "from-cyan-400 via-blue-500 to-indigo-800",
|
||||
night: "from-slate-800 via-indigo-950 to-slate-950",
|
||||
mild: "from-sky-500 via-cyan-600 to-blue-800",
|
||||
}[mood];
|
||||
}
|
||||
Reference in New Issue
Block a user