Weather Station / ECOWITT / DNT
1"use client";
2
3import React, { useEffect, useMemo, useState } from "react";
4import { useTranslation } from "react-i18next";
5import { API_ENDPOINTS } from "@/constants";
6import type { StatisticsPayload, YearStats, MonthStats, ThresholdList } from "@/types/statistics";
7import StatisticsKpis from "@/components/StatisticsKpis";
8import StatisticsLegend from "@/components/StatisticsLegend";
9import CalendarHeatmap from "@/components/CalendarHeatmap";
10import TopExtremes from "@/components/TopExtremes";
11
12function fmtNum(n: number | null | undefined, fraction = 1) {
13 if (n === null || n === undefined || !Number.isFinite(n)) return "–";
14 return new Intl.NumberFormat(undefined, { maximumFractionDigits: fraction, minimumFractionDigits: 0 }).format(n);
15}
16
17function fmtDate(d: string | null | undefined) {
18 if (!d) return "–";
19 const [y, m, day] = d.split("-").map(Number);
20 const date = new Date(y, (m || 1) - 1, day || 1);
21 return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
22}
23
24function tempColorClass(v: number | null | undefined) {
25 if (v === null || v === undefined || !Number.isFinite(v)) return "";
26 if (v <= -10) return "text-blue-900"; // dunkelblau
27 if (v <= 0) return "text-blue-500"; // hellblau
28 if (v <= 20) return "text-green-600"; // grün
29 if (v <= 25) return "text-orange-500"; // orange
30 if (v < 30) return "text-orange-600"; // Richtung rot
31 return "text-red-600"; // rot
32}
33
34function ThresholdItem({ label, td, className, unit }: { label: string; td?: ThresholdList; className?: string; unit?: string }) {
35 const [open, setOpen] = useState(false);
36 const [sortBy, setSortBy] = useState<"date" | "value">("date");
37 const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
38 const items = useMemo(() => {
39 const anyTd: any = td as any;
40 if (!anyTd) return [] as { date: string; value: number }[];
41 if (Array.isArray(anyTd.items)) return anyTd.items as { date: string; value: number }[];
42 if (Array.isArray(anyTd.dates)) return (anyTd.dates as string[]).map((d) => ({ date: d, value: NaN }));
43 return [] as { date: string; value: number }[];
44 }, [td]);
45 const sorted = useMemo(() => {
46 const arr = items.slice();
47 arr.sort((a, b) => {
48 if (sortBy === "value") {
49 const av = Number.isFinite(a.value) ? a.value : Number.NEGATIVE_INFINITY;
50 const bv = Number.isFinite(b.value) ? b.value : Number.NEGATIVE_INFINITY;
51 return (av - bv) * (sortDir === "asc" ? 1 : -1);
52 }
53 return (a.date.localeCompare(b.date)) * (sortDir === "asc" ? 1 : -1);
54 });
55 return arr;
56 }, [items, sortBy, sortDir]);
57 const count = (td && typeof (td as any).count === "number") ? (td as any).count as number : items.length;
58 return (
59 <div className="mb-2">
60 <button
61 className={"text-sm font-medium hover:underline " + (className || "text-blue-600")}
62 onClick={() => setOpen((v) => !v)}
63 aria-expanded={open}
64 >
65 {label}: {count}
66 </button>
67 {open && items.length > 0 && (
68 <div className="mt-1 mb-1 text-[11px] text-gray-600 flex items-center gap-2">
69 <span>Sort:</span>
70 <button className="underline" onClick={() => setSortBy("date")}>Date</button>
71 <button className="underline" onClick={() => setSortBy("value")}>Value</button>
72 <button className="underline" onClick={() => setSortDir((d) => d === "asc" ? "desc" : "asc")}>{sortDir.toUpperCase()}</button>
73 </div>
74 )}
75 {open && items.length > 0 && (
76 <ul className="mt-1 ml-4 list-disc text-sm text-gray-700 dark:text-gray-300">
77 {sorted.map((it) => {
78 const badgeBase = "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-neutral-800";
79 const colorCls = unit === "°C" ? tempColorClass(it.value) : "";
80 return (
81 <li key={it.date}>
82 {fmtDate(it.date)} — <span className={`${badgeBase} ${colorCls}`}>{fmtNum(it.value)}{unit ? ` ${unit}` : ""}</span>
83 </li>
84 );
85 })}
86 </ul>
87 )}
88 </div>
89 );
90}
91
92function TemperatureBlock({ y, stacked }: { y: YearStats | MonthStats; stacked?: boolean }) {
93 const { t } = useTranslation();
94 const temp = y.temperature;
95 return (
96 <div className="p-3 rounded border border-gray-200 dark:border-neutral-800">
97 <div className="font-semibold mb-2">{t("statistics.temperature", "Temperature")}</div>
98 {stacked ? (
99 <div className="text-sm space-y-1">
100 <div>
101 {t("dashboard.highestTemperature")} : <span className={tempColorClass(temp.max)}>{fmtNum(temp.max)} °C</span><br />
102 <span className="text-xs text-gray-600">({fmtDate(temp.maxDate)})</span>
103 </div>
104 <div>
105 {t("dashboard.lowestTemperature")} : <span className={tempColorClass(temp.min)}>{fmtNum(temp.min)} °C</span><br />
106 <span className="text-xs text-gray-600">({fmtDate(temp.minDate)})</span>
107 </div>
108 <div>
109 {t("dashboard.average")} : {fmtNum(temp.avg)} °C
110 </div>
111 </div>
112 ) : (
113 <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
114 <div>{t("dashboard.highestTemperature")} : <span className={tempColorClass(temp.max)}>{fmtNum(temp.max)} °C</span> ({fmtDate(temp.maxDate)})</div>
115 <div>{t("dashboard.lowestTemperature")} : <span className={tempColorClass(temp.min)}>{fmtNum(temp.min)} °C</span> ({fmtDate(temp.minDate)})</div>
116 <div>{t("dashboard.average")} : {fmtNum(temp.avg)} °C</div>
117 </div>
118 )}
119 <div className="mt-2">
120 <ThresholdItem className="text-red-600" label={t("dashboard.daysOver30C")} td={temp.over30} unit="°C" />
121 <ThresholdItem className="text-orange-500" label={t("statistics.daysOver25C", "Days > 25 °C")} td={temp.over25} unit="°C" />
122 <ThresholdItem className="text-green-600" label={t("statistics.daysOver20C", "Days > 20 °C")} td={temp.over20} unit="°C" />
123 <ThresholdItem className="text-blue-500" label={t("dashboard.daysUnder0C")} td={temp.under0} unit="°C" />
124 <ThresholdItem className="text-blue-900" label={t("statistics.daysUnder10C", "Days < -10 °C")} td={temp.under10} unit="°C" />
125 </div>
126 </div>
127 );
128}
129
130function PrecipitationBlock({ y, stacked }: { y: YearStats | MonthStats; stacked?: boolean }) {
131 const { t } = useTranslation();
132 const p = y.precipitation;
133 return (
134 <div className="p-3 rounded border border-gray-200 dark:border-neutral-800">
135 <div className="font-semibold mb-2">{t("statistics.precipitation", "Precipitation")}</div>
136 {stacked ? (
137 <div className="text-sm space-y-1">
138 <div>{t("dashboard.total")} : {fmtNum(p.total)} mm</div>
139 <div>
140 {t("statistics.maxDay", "Max day")} : {fmtNum(p.maxDay)} mm<br />
141 <span className="text-xs text-gray-600">({fmtDate(p.maxDayDate)})</span>
142 </div>
143 <div>
144 {t("statistics.minDay", "Min day")} : {fmtNum(p.minDay)} mm<br />
145 <span className="text-xs text-gray-600">({fmtDate(p.minDayDate)})</span>
146 </div>
147 </div>
148 ) : (
149 <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
150 <div>{t("dashboard.total")} : {fmtNum(p.total)} mm</div>
151 <div>{t("statistics.maxDay", "Max day")} : {fmtNum(p.maxDay)} mm ({fmtDate(p.maxDayDate)})</div>
152 <div>{t("statistics.minDay", "Min day")} : {fmtNum(p.minDay)} mm ({fmtDate(p.minDayDate)})</div>
153 </div>
154 )}
155 <div className="mt-2">
156 <ThresholdItem label={t("statistics.daysOver20mm", "Days ≥ 20 mm")} td={p.over20mm} unit="mm" />
157 <ThresholdItem label={t("dashboard.daysOver30mm")} td={p.over30mm} unit="mm" />
158 </div>
159 </div>
160 );
161}
162
163function WindBlock({ y, stacked }: { y: YearStats | MonthStats; stacked?: boolean }) {
164 const { t } = useTranslation();
165 const w = y.wind;
166 return (
167 <div className="p-3 rounded border border-gray-200 dark:border-neutral-800">
168 <div className="font-semibold mb-2">{t("statistics.wind", "Wind")}</div>
169 {stacked ? (
170 <div className="text-sm space-y-1">
171 <div>
172 {t("dashboard.highestWind")} : {fmtNum(w.max)} km/h<br />
173 <span className="text-xs text-gray-600">({fmtDate(w.maxDate)})</span>
174 </div>
175 <div>
176 {t("dashboard.highestGust")} : {fmtNum(w.gustMax)} km/h<br />
177 <span className="text-xs text-gray-600">({fmtDate(w.gustMaxDate)})</span>
178 </div>
179 </div>
180 ) : (
181 <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
182 <div>{t("dashboard.highestWind")} : {fmtNum(w.max)} km/h ({fmtDate(w.maxDate)})</div>
183 <div>{t("dashboard.highestGust")} : {fmtNum(w.gustMax)} km/h ({fmtDate(w.gustMaxDate)})</div>
184 </div>
185 )}
186 </div>
187 );
188}
189
190function MonthSection({ m }: { m: MonthStats }) {
191 const [open, setOpen] = useState(false);
192 const monthLabel = useMemo(() => {
193 const dt = new Date(m.year, m.month - 1, 1);
194 return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(dt);
195 }, [m.year, m.month]);
196 const t = m.temperature;
197 const p = m.precipitation;
198 return (
199 <div className="stat-card">
200 <button
201 onClick={() => setOpen((v) => !v)}
202 className="w-full text-left px-3 py-2"
203 aria-expanded={open}
204 >
205 <div className="font-medium">{monthLabel}</div>
206 {!open && (
207 <div className="text-xs text-gray-600 mt-1">
208 Tmax {fmtNum(t.max)} °C · Tmin {fmtNum(t.min)} °C · Rain {fmtNum(p.total)} mm
209 </div>
210 )}
211 </button>
212 {open && (
213 <div className="p-3 grid gap-3">
214 <TemperatureBlock y={m} stacked />
215 <PrecipitationBlock y={m} stacked />
216 <WindBlock y={m} stacked />
217 </div>
218 )}
219 </div>
220 );
221}
222
223function YearSection({ y }: { y: YearStats }) {
224 const { t } = useTranslation();
225 const [open, setOpen] = useState(false);
226 return (
227 <div className="mb-4">
228 <button
229 onClick={() => setOpen((v) => !v)}
230 className="w-full text-left px-3 py-2 rounded bg-gray-100 dark:bg-neutral-800 hover:bg-gray-200 dark:hover:bg-neutral-700"
231 aria-expanded={open}
232 >
233 <span className="font-semibold mr-2">{t("dashboard.year")}: {y.year}</span>
234 </button>
235 {open && (
236 <div className="mt-2 grid gap-3">
237 <TemperatureBlock y={y} />
238 <PrecipitationBlock y={y} />
239 <WindBlock y={y} />
240 <div className="mt-2">
241 <div className="font-medium mb-2">{t("dashboard.month")}</div>
242 <div className="stat-grid">
243 {y.months.map((m) => (
244 <MonthSection key={`${y.year}-${m.month}`} m={m} />
245 ))}
246 </div>
247 </div>
248 </div>
249 )}
250 </div>
251 );
252}
253
254export default function Statistics() {
255 const { t } = useTranslation();
256 const [data, setData] = useState<StatisticsPayload | null>(null);
257 const [loading, setLoading] = useState(true);
258 const [error, setError] = useState<string | null>(null);
259 const [selectedYear, setSelectedYear] = useState<number | null>(null);
260
261 useEffect(() => {
262 const ac = new AbortController();
263 let cancelled = false;
264 (async () => {
265 try {
266 setLoading(true);
267 setError(null);
268 const res = await fetch(API_ENDPOINTS.STATISTICS, { signal: ac.signal });
269 if (!res.ok) throw new Error(`HTTP ${res.status}`);
270 const json = await res.json();
271 if (!json?.ok) throw new Error(String(json?.error || "unknown error"));
272 // Ensure stable sorting regardless of cached order
273 const yearsSorted = (json.years || [])
274 .slice()
275 .sort((a: any, b: any) => (b?.year ?? 0) - (a?.year ?? 0))
276 .map((y: any) => ({
277 ...y,
278 months: (y.months || []).slice().sort((m1: any, m2: any) => (m1?.month ?? 0) - (m2?.month ?? 0)),
279 }));
280 if (!cancelled) setData({ updatedAt: json.updatedAt, years: yearsSorted });
281 } catch (e: any) {
282 const msg = String(e?.message || e || "");
283 if (cancelled) return; // effect cleaned up
284 // Ignore abort errors (HMR/tab switch cleanup)
285 if (e?.name === "AbortError" || msg.toLowerCase().includes("abort")) {
286 return;
287 }
288 setError(msg);
289 } finally {
290 if (!cancelled) setLoading(false);
291 }
292 })();
293 return () => {
294 cancelled = true;
295 ac.abort();
296 };
297 }, []);
298
299 const yearsSorted = useMemo(() => {
300 return (data?.years || []).slice().sort((a, b) => (b?.year ?? 0) - (a?.year ?? 0));
301 }, [data?.years]);
302
303 // Default selection to latest year when data arrives
304 useEffect(() => {
305 if (!selectedYear && yearsSorted.length > 0) {
306 setSelectedYear(yearsSorted[0].year);
307 }
308 }, [yearsSorted, selectedYear]);
309
310 const currentYearStats = useMemo(() => {
311 if (yearsSorted.length === 0) return null;
312 if (selectedYear == null) return yearsSorted[0];
313 return yearsSorted.find((y) => y.year === selectedYear) || yearsSorted[0];
314 }, [yearsSorted, selectedYear]);
315
316 return (
317 <div>
318 <div className="mb-3 flex items-center justify-between">
319 <h2 className="text-lg font-semibold">{t("tabs.statistics", "Statistics")}</h2>
320 <div className="flex items-center gap-3">
321 <label className="text-xs text-gray-500">
322 {t("dashboard.year")}:
323 <select
324 className="text-xs bg-white dark:bg-neutral-900 border border-gray-300 dark:border-neutral-700 rounded px-2 py-1"
325 value={currentYearStats?.year ?? ""}
326 onChange={(e) => setSelectedYear(Number(e.target.value))}
327 >
328 {yearsSorted.map((y) => (
329 <option key={y.year} value={y.year}>{y.year}</option>
330 ))}
331 </select>
332 </label>
333 <div className="text-xs text-gray-500">
334 {t("statuses.lastUpdate")} {data?.updatedAt ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(data.updatedAt)) : "–"}
335 </div>
336 </div>
337 </div>
338 {currentYearStats && (
339 <div>
340 <StatisticsKpis y={currentYearStats} />
341 <StatisticsLegend />
342 <CalendarHeatmap year={currentYearStats.year} />
343 <CalendarHeatmap
344 year={currentYearStats.year}
345 metric="tmin"
346 />
347 <TopExtremes year={currentYearStats.year} />
348 </div>
349 )}
350 {loading && <div className="text-sm text-gray-600 dark:text-gray-300">{t("statuses.loading")}</div>}
351 {error && <div className="text-sm text-red-600">{t("statuses.error")}: {error}</div>}
352 {!loading && !error && data && data.years.length === 0 && (
353 <div className="text-sm text-gray-600 dark:text-gray-300">{t("statuses.noData")}</div>
354 )}
355 {!loading && !error && yearsSorted.length > 0 && (
356 <div>
357 {yearsSorted.map((y) => (
358 <YearSection key={y.year} y={y} />
359 ))}
360 </div>
361 )}
362 </div>
363 );
364}