Weather Station / ECOWITT / DNT
0

Configure Feed

Select the types of activity you want to include in your feed.

at stats2.0 15 kB View raw
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")}:&nbsp; 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}