Weather Station / ECOWITT / DNT
0

Configure Feed

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

stats speed up

+577 -47
+188
src/app/api/statistics/channels/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import path from "path"; 3 + import { getDuckConn } from "@/lib/db/duckdb"; 4 + import { ensureAllsensorsParquetsInRange } from "@/lib/db/ingest"; 5 + import { sqlNum } from "@/lib/data/columns"; 6 + 7 + export const runtime = "nodejs"; 8 + 9 + function parseDateParam(v: string | null): Date | undefined { 10 + if (!v) return undefined; 11 + const s = v.replace("T", " "); 12 + const d = new Date(s); 13 + if (isNaN(d.getTime())) return undefined; 14 + return d; 15 + } 16 + 17 + function monthRange(month: string): { start: Date; end: Date } | null { 18 + if (!/^\d{6}$/.test(month)) return null; 19 + const y = Number(month.slice(0, 4)); 20 + const m = Number(month.slice(4, 6)); 21 + const start = new Date(y, m - 1, 1, 0, 0, 0, 0); 22 + const end = new Date(y, m, 0, 23, 59, 59, 999); 23 + return { start, end }; 24 + } 25 + 26 + function toIsoMinute(d: Date) { 27 + const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n)); 28 + const yyyy = d.getFullYear(); 29 + const mm = pad2(d.getMonth() + 1); 30 + const dd = pad2(d.getDate()); 31 + const hh = pad2(d.getHours()); 32 + const mi = pad2(d.getMinutes()); 33 + return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; 34 + } 35 + 36 + function findChannelMetricColumns(allNames: string[], chNum: string) { 37 + const pref = `CH${chNum} `; 38 + const tempNames = ["Temperatur", "Temperature"]; 39 + const feelNames = ["Gefühlte Temperatur", "Wärmeindex", "Feels Like", "Heat Index"]; 40 + const humNames = ["Luftfeuchtigkeit", "Humidity", "hum"]; // currently unused for stats 41 + 42 + const findFirst = (cands: string[]) => { 43 + for (const n of allNames) { 44 + if (!n.startsWith(pref)) continue; 45 + for (const m of cands) { 46 + if (n.startsWith(pref + m)) return n; 47 + } 48 + } 49 + return null as string | null; 50 + }; 51 + 52 + const tempCol = findFirst(tempNames); 53 + const feelCol = findFirst(feelNames); 54 + const humCol = findFirst(humNames); 55 + return { tempCol, feelCol, humCol }; 56 + } 57 + 58 + /** 59 + * GET /api/statistics/channels?ch=ch1&start=YYYY-MM-DDTHH:MM&end=YYYY-MM-DDTHH:MM 60 + * Or: /api/statistics/channels?ch=ch1&month=YYYYMM 61 + * Returns server-side computed temperature statistics for the selected channel over the range. 62 + */ 63 + export async function GET(req: NextRequest) { 64 + try { 65 + const { searchParams } = new URL(req.url); 66 + const ch = (searchParams.get("ch") || "").trim().toLowerCase(); // e.g. ch1 67 + const month = searchParams.get("month"); 68 + const startParam = searchParams.get("start"); 69 + const endParam = searchParams.get("end"); 70 + 71 + if (!/^ch\d+$/.test(ch)) return NextResponse.json({ ok: false, error: "Invalid channel" }, { status: 400 }); 72 + const chNum = ch.replace(/^ch/, ""); 73 + 74 + let start: Date | undefined; 75 + let end: Date | undefined; 76 + 77 + if (month) { 78 + const r = monthRange(month); 79 + if (!r) return NextResponse.json({ ok: false, error: "Invalid month" }, { status: 400 }); 80 + start = r.start; end = r.end; 81 + } else { 82 + start = parseDateParam(startParam); 83 + end = parseDateParam(endParam); 84 + if (!start || !end) return NextResponse.json({ ok: false, error: "Missing start or end" }, { status: 400 }); 85 + } 86 + 87 + const parquets = await ensureAllsensorsParquetsInRange(start, end); 88 + if (!parquets.length) return NextResponse.json({ ok: false, error: "No data in range" }, { status: 404 }); 89 + 90 + const conn = await getDuckConn(); 91 + const qp = parquets.map((p) => p.replace(/\\/g, "/")); 92 + const arr = '[' + qp.map((p) => `\'${p}\'`).join(',') + ']'; 93 + 94 + // Introspect columns 95 + const descReader = await conn.runAndReadAll(`DESCRIBE SELECT * FROM read_parquet(${arr}, union_by_name=true)`); 96 + const cols: any[] = descReader.getRowObjects(); 97 + const allNames = cols.map((r: any) => String(r.column_name || r.ColumnName || r.column || "")); 98 + 99 + const { tempCol, feelCol } = findChannelMetricColumns(allNames, chNum); 100 + if (!tempCol) return NextResponse.json({ ok: false, error: "No temperature column for channel" }, { status: 404 }); 101 + 102 + const whereStart = start ? `ts >= strptime('${toIsoMinute(start).replace('T',' ')}', ['%Y-%m-%d %H:%M'])` : '1=1'; 103 + const whereEnd = end ? `ts <= strptime('${toIsoMinute(end).replace('T',' ')}', ['%Y-%m-%d %H:%M'])` : '1=1'; 104 + 105 + const tExpr = sqlNum('"' + tempCol.replace(/"/g, '""') + '"'); 106 + const feelExpr = feelCol ? sqlNum('"' + feelCol.replace(/"/g, '""') + '"') : 'NULL'; 107 + 108 + const sql = ` 109 + WITH src AS ( 110 + SELECT * FROM read_parquet(${arr}, union_by_name=true) 111 + ), 112 + casted AS ( 113 + SELECT ts, 114 + ${tExpr} AS t, 115 + ${feelExpr} AS tf 116 + FROM src 117 + WHERE ts IS NOT NULL AND ${whereStart} AND ${whereEnd} 118 + ), 119 + daily AS ( 120 + SELECT 121 + date_trunc('day', ts) AS d, 122 + max(t) AS tmax, 123 + min(t) AS tmin, 124 + avg(t) AS tavg, 125 + max(tf) AS tfmax, 126 + min(tf) AS tfmin 127 + FROM casted 128 + GROUP BY 1 129 + ) 130 + SELECT strftime(d, '%Y-%m-%d') AS day, 131 + tmax, tmin, tavg, 132 + tfmax, tfmin 133 + FROM daily 134 + ORDER BY day; 135 + `; 136 + 137 + const reader = await conn.runAndReadAll(sql); 138 + const days = reader.getRowObjects() as Array<{ day: string; tmax: number | null; tmin: number | null; tavg: number | null; tfmax: number | null; tfmin: number | null; }>; 139 + 140 + // Compute stats from daily rows 141 + let tMax = -Infinity, tMaxDate: string | null = null; 142 + let tMin = Infinity, tMinDate: string | null = null; 143 + let tAvgSum = 0, tAvgCnt = 0; 144 + const over30: { date: string; value: number }[] = []; 145 + const under0: { date: string; value: number }[] = []; 146 + for (const r of days) { 147 + const d = r.day; 148 + const tx = typeof r.tmax === 'number' && Number.isFinite(r.tmax) ? r.tmax : null; 149 + const tn = typeof r.tmin === 'number' && Number.isFinite(r.tmin) ? r.tmin : null; 150 + const ta = typeof r.tavg === 'number' && Number.isFinite(r.tavg) ? r.tavg : null; 151 + if (tx !== null) { if (tx > tMax) { tMax = tx; tMaxDate = d; } if (tx > 30) over30.push({ date: d, value: tx }); } 152 + if (tn !== null) { if (tn < tMin) { tMin = tn; tMinDate = d; } if (tn < 0) under0.push({ date: d, value: tn }); } 153 + if (ta !== null) { tAvgSum += ta; tAvgCnt++; } 154 + } 155 + const temp = { 156 + max: Number.isFinite(tMax) ? tMax : null, 157 + maxDate: tMaxDate, 158 + min: Number.isFinite(tMin) ? tMin : null, 159 + minDate: tMinDate, 160 + avg: tAvgCnt > 0 ? tAvgSum / tAvgCnt : null, 161 + over30: { count: over30.length, items: over30 }, 162 + over25: { count: days.filter(d => typeof d.tmax === 'number' && (d.tmax as number) > 25).length, items: days.filter(d => typeof d.tmax === 'number' && (d.tmax as number) > 25).map(d => ({ date: d.day, value: d.tmax as number })) }, 163 + over20: { count: days.filter(d => typeof d.tmax === 'number' && (d.tmax as number) > 20).length, items: days.filter(d => typeof d.tmax === 'number' && (d.tmax as number) > 20).map(d => ({ date: d.day, value: d.tmax as number })) }, 164 + under0: { count: under0.length, items: under0 }, 165 + under10: { count: days.filter(d => typeof d.tmin === 'number' && (d.tmin as number) <= -10).length, items: days.filter(d => typeof d.tmin === 'number' && (d.tmin as number) <= -10).map(d => ({ date: d.day, value: d.tmin as number })) }, 166 + }; 167 + 168 + // Feels-like optional 169 + let feelMax: number | null = null, feelMaxDate: string | null = null; 170 + let feelMin: number | null = null, feelMinDate: string | null = null; 171 + if (days.some(d => d.tfmax != null || d.tfmin != null)) { 172 + for (const r of days) { 173 + const d = r.day; 174 + const fx = typeof r.tfmax === 'number' && Number.isFinite(r.tfmax) ? r.tfmax : null; 175 + const fn = typeof r.tfmin === 'number' && Number.isFinite(r.tfmin) ? r.tfmin : null; 176 + if (fx !== null && (feelMax == null || fx > (feelMax as number))) { feelMax = fx; feelMaxDate = d; } 177 + if (fn !== null && (feelMin == null || fn < (feelMin as number))) { feelMin = fn; feelMinDate = d; } 178 + } 179 + } 180 + 181 + const totalPeriodDays = Math.floor((end!.getTime() - start!.getTime()) / (24 * 60 * 60 * 1000)) + 1; 182 + 183 + return NextResponse.json({ ok: true, ch, start: toIsoMinute(start!), end: toIsoMinute(end!), totalPeriodDays, days, stats: { temp, feels: { max: feelMax, maxDate: feelMaxDate, min: feelMin, minDate: feelMinDate } } }); 184 + } catch (e: any) { 185 + console.error("[statistics/channels] GET error:", e?.message || e); 186 + return NextResponse.json({ ok: false, error: String(e?.message || e) }, { status: 500 }); 187 + } 188 + }
+81
src/app/api/statistics/range/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { ensureMainParquetsInRange } from "@/lib/db/ingest"; 3 + import { queryDailyAggregatesInRange, computeStatsFromDaily, type DailyAggregateRow } from "@/lib/statistics"; 4 + 5 + export const runtime = "nodejs"; 6 + 7 + function parseDateParam(v: string | null): Date | undefined { 8 + if (!v) return undefined; 9 + const s = v.replace("T", " "); 10 + const d = new Date(s); 11 + if (isNaN(d.getTime())) return undefined; 12 + return d; 13 + } 14 + 15 + function monthRange(month: string): { start: Date; end: Date } | null { 16 + if (!/^\d{6}$/.test(month)) return null; 17 + const y = Number(month.slice(0, 4)); 18 + const m = Number(month.slice(4, 6)); 19 + const start = new Date(y, m - 1, 1, 0, 0, 0, 0); 20 + const end = new Date(y, m, 0, 23, 59, 59, 999); 21 + return { start, end }; 22 + } 23 + 24 + function toIsoMinute(d: Date) { 25 + const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n)); 26 + const yyyy = d.getFullYear(); 27 + const mm = pad2(d.getMonth() + 1); 28 + const dd = pad2(d.getDate()); 29 + const hh = pad2(d.getHours()); 30 + const mi = pad2(d.getMinutes()); 31 + return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; 32 + } 33 + 34 + function daysInclusive(a: Date, b: Date) { 35 + const d0 = new Date(a.getFullYear(), a.getMonth(), a.getDate()); 36 + const d1 = new Date(b.getFullYear(), b.getMonth(), b.getDate()); 37 + const ms = d1.getTime() - d0.getTime(); 38 + return Math.floor(ms / (24 * 60 * 60 * 1000)) + 1; 39 + } 40 + 41 + /** 42 + * GET /api/statistics/range?start=YYYY-MM-DDTHH:MM&end=YYYY-MM-DDTHH:MM 43 + * Or: /api/statistics/range?month=YYYYMM 44 + * Returns server-side computed statistics (daily-based) for the selected range. 45 + */ 46 + export async function GET(req: NextRequest) { 47 + try { 48 + const { searchParams } = new URL(req.url); 49 + const month = searchParams.get("month"); 50 + const startParam = searchParams.get("start"); 51 + const endParam = searchParams.get("end"); 52 + 53 + let start: Date | undefined; 54 + let end: Date | undefined; 55 + 56 + if (month) { 57 + const r = monthRange(month); 58 + if (!r) return NextResponse.json({ ok: false, error: "Invalid month" }, { status: 400 }); 59 + start = r.start; end = r.end; 60 + } else { 61 + start = parseDateParam(startParam); 62 + end = parseDateParam(endParam); 63 + if (!start || !end) return NextResponse.json({ ok: false, error: "Missing start or end" }, { status: 400 }); 64 + } 65 + 66 + const parquets = await ensureMainParquetsInRange(start, end); 67 + if (!parquets.length) return NextResponse.json({ ok: false, error: "No data in range" }, { status: 404 }); 68 + 69 + const days: DailyAggregateRow[] = await queryDailyAggregatesInRange(parquets, start, end); 70 + const { temp, rain, wind, rainDays } = computeStatsFromDaily(days); 71 + 72 + const startIso = toIsoMinute(start!); 73 + const endIso = toIsoMinute(end!); 74 + const totalPeriodDays = daysInclusive(start!, end!); 75 + 76 + return NextResponse.json({ ok: true, start: startIso, end: endIso, totalPeriodDays, days, stats: { temp, rain, wind, rainDays } }); 77 + } catch (e: any) { 78 + console.error("[statistics/range] GET error:", e?.message || e); 79 + return NextResponse.json({ ok: false, error: String(e?.message || e) }, { status: 500 }); 80 + } 81 + }
+95 -47
src/components/Dashboard.tsx
··· 45 45 chKey: string, 46 46 minuteDataAll: DataResp | null, 47 47 t: (key: string) => string, 48 - locale: string 48 + locale: string, 49 + serverChannelStats?: any 49 50 ) { 50 51 const rows = data.rows || []; 51 52 if (!rows.length || !xBase) return <div className="text-xs text-gray-500">{t('statuses.noData')}</div>; ··· 807 808 const [dataMain, setDataMain] = useState<DataResp | null>(null); 808 809 // Minutendaten für Statistikberechnung 809 810 const [minuteDataMain, setMinuteDataMain] = useState<DataResp | null>(null); 811 + // Serverseitig berechnete Statistiken für den gewählten Zeitraum (Main-Sensoren) 812 + const [rangeStats, setRangeStats] = useState<any | null>(null); 813 + // Serverseitig berechnete Statistiken für den ausgewählten Kanal (im Single-Channel-Modus) 814 + const [channelStats, setChannelStats] = useState<any | null>(null); 810 815 const [minuteDataAll, setMinuteDataAll] = useState<DataResp | null>(null); 811 816 const [channelsCfg, setChannelsCfg] = useState<ChannelsConfig>({}); 812 817 const [loading, setLoading] = useState<boolean>(false); ··· 953 958 setErrMain(null); 954 959 const uAll = new URL(API_ENDPOINTS.DATA_ALLSENSORS, window.location.origin); 955 960 const uMain = new URL(API_ENDPOINTS.DATA_MAIN, window.location.origin); 956 - const uMinuteMain = new URL(API_ENDPOINTS.DATA_MAIN, window.location.origin); 957 - const uMinuteAll = new URL(API_ENDPOINTS.DATA_ALLSENSORS, window.location.origin); 961 + // Serverseitige Statistik-Endpoints 962 + const uRange = new URL(API_ENDPOINTS.STATISTICS_RANGE, window.location.origin); 963 + // Kanal-Statistiken nur im Single-Channel-Modus abrufen 964 + const needsChannelStats = (mode === "channel" && selectedChannel !== "all"); 965 + const uChannel = new URL(API_ENDPOINTS.STATISTICS_CHANNELS, window.location.origin); 958 966 959 967 if (useGlobalRange) { 960 968 uAll.searchParams.set("resolution", resolution); 961 969 uMain.searchParams.set("resolution", resolution); 962 - // Minutendaten immer mit Auflösung "minute" laden 963 - uMinuteMain.searchParams.set("resolution", "minute"); 964 - uMinuteAll.searchParams.set("resolution", "minute"); 965 970 966 971 if (startParam) { 967 972 uAll.searchParams.set("start", startParam); 968 973 uMain.searchParams.set("start", startParam); 969 - uMinuteMain.searchParams.set("start", startParam); 970 - uMinuteAll.searchParams.set("start", startParam); 974 + uRange.searchParams.set("start", startParam); 975 + if (needsChannelStats) { 976 + uChannel.searchParams.set("start", startParam); 977 + } 971 978 } 972 979 973 980 if (endParam) { 974 981 uAll.searchParams.set("end", endParam); 975 982 uMain.searchParams.set("end", endParam); 976 - uMinuteMain.searchParams.set("end", endParam); 977 - uMinuteAll.searchParams.set("end", endParam); 983 + uRange.searchParams.set("end", endParam); 984 + if (needsChannelStats) { 985 + uChannel.searchParams.set("end", endParam); 986 + } 987 + } 988 + if (needsChannelStats) { 989 + uChannel.searchParams.set("ch", selectedChannel); 978 990 } 979 991 } else { 980 992 const monthStr = `${year}${mon}`; 981 993 uAll.searchParams.set("month", monthStr); 982 994 uMain.searchParams.set("month", monthStr); 983 - uMinuteMain.searchParams.set("month", monthStr); 984 - uMinuteAll.searchParams.set("month", monthStr); 995 + uRange.searchParams.set("month", monthStr); 996 + if (needsChannelStats) { 997 + uChannel.searchParams.set("month", monthStr); 998 + uChannel.searchParams.set("ch", selectedChannel); 999 + } 985 1000 986 1001 uAll.searchParams.set("resolution", resolution); 987 1002 uMain.searchParams.set("resolution", resolution); 988 - // Minutendaten immer mit Auflösung "minute" laden 989 - uMinuteMain.searchParams.set("resolution", "minute"); 990 - uMinuteAll.searchParams.set("resolution", "minute"); 991 1003 } 992 1004 993 - Promise.all([ 1005 + const promises: Promise<{ ok: boolean; body: any }>[] = [ 994 1006 fetch(uAll.toString()).then(async (r) => ({ ok: r.ok, body: await r.json() })).catch(() => ({ ok: false, body: null })), 995 1007 fetch(uMain.toString()).then(async (r) => ({ ok: r.ok, body: await r.json() })).catch(() => ({ ok: false, body: null })), 996 - fetch(uMinuteMain.toString()).then(async (r) => ({ ok: r.ok, body: await r.json() })).catch(() => ({ ok: false, body: null })), 997 - fetch(uMinuteAll.toString()).then(async (r) => ({ ok: r.ok, body: await r.json() })).catch(() => ({ ok: false, body: null })), 998 - ]) 999 - .then(([a, m, mm, mma]) => { 1008 + fetch(uRange.toString()).then(async (r) => ({ ok: r.ok, body: await r.json() })).catch(() => ({ ok: false, body: null })), 1009 + needsChannelStats 1010 + ? fetch(uChannel.toString()).then(async (r) => ({ ok: r.ok, body: await r.json() })).catch(() => ({ ok: false, body: null })) 1011 + : Promise.resolve({ ok: true, body: null }), 1012 + ]; 1013 + 1014 + Promise.all(promises) 1015 + .then(([a, m, rs, cs]) => { 1000 1016 if (!a.ok || !a.body || a.body.error) { 1001 1017 setErrAll(a.body?.error || t('statuses.loadErrorAllsensors')); 1002 1018 setDataAll(null); ··· 1010 1026 } else { 1011 1027 setDataMain(m.body); 1012 1028 } 1013 - 1014 - // Minutendaten für Statistikberechnung setzen 1015 - if (!mm.ok || !mm.body || mm.body.error) { 1016 - // Minute-data load error (non-critical, used only for statistics) 1017 - console.warn(t('statuses.minuteDataWarning'), mm.body?.error); 1018 - setMinuteDataMain(null); 1029 + // Serverseitige Bereichs-Statistiken 1030 + if (!rs.ok || !rs.body || rs.body.ok === false) { 1031 + setRangeStats(null); 1019 1032 } else { 1020 - setMinuteDataMain(mm.body); 1033 + setRangeStats(rs.body); 1021 1034 } 1022 - if (!mma.ok || !mma.body || mma.body.error) { 1023 - console.warn(t('statuses.minuteDataWarningAllsensors'), mma.body?.error); 1024 - setMinuteDataAll(null); 1035 + // Kanal-Statistik optional 1036 + if (!needsChannelStats || !cs.ok || !cs.body || cs.body.ok === false) { 1037 + setChannelStats(null); 1025 1038 } else { 1026 - setMinuteDataAll(mma.body); 1039 + setChannelStats(cs.body); 1027 1040 } 1041 + // Minutendaten nicht mehr clientseitig laden 1042 + setMinuteDataMain(null); 1043 + setMinuteDataAll(null); 1028 1044 }) 1029 1045 .finally(() => setLoading(false)); 1030 1046 }, [useGlobalRange, startParam, endParam, year, mon, resolution, mode, selectedChannel]); ··· 1278 1294 {mode === "main" && ( 1279 1295 <div className={styles.sectionCard}> 1280 1296 {dataMain 1281 - ? renderMainCharts(dataMain, xBaseMain, minuteDataMain, t, locale) 1297 + ? renderMainCharts(dataMain, xBaseMain, minuteDataMain, t, locale, rangeStats) 1282 1298 : <div className={styles.emptyState}>{t('statuses.noData')}</div>} 1283 1299 </div> 1284 1300 )} ··· 1313 1329 {dataAll 1314 1330 ? (selectedChannel === "all" 1315 1331 ? renderAllChannelsCharts(dataAll, channelsCfg, xBaseAll, minuteDataAll, t, locale) 1316 - : renderChannelCardCharts(dataAll, channelsCfg, xBaseAll, selectedChannel, minuteDataAll, t, locale)) 1332 + : renderChannelCardCharts(dataAll, channelsCfg, xBaseAll, selectedChannel, minuteDataAll, t, locale, channelStats)) 1317 1333 : <div className={styles.emptyState}>{t('statuses.noData')}</div>} 1318 1334 </div> 1319 1335 </div> ··· 1372 1388 * @returns A React fragment containing all the charts for the main sensors. 1373 1389 * @private 1374 1390 */ 1375 - function renderMainCharts(data: DataResp, xBase: number | null, minuteData: DataResp | null, t: (key: string) => string, locale: string) { 1391 + function renderMainCharts(data: DataResp, xBase: number | null, minuteData: DataResp | null, t: (key: string) => string, locale: string, serverRangeStats?: any) { 1376 1392 const rows = data.rows || []; 1377 1393 if (!rows.length || !xBase) return <div className="text-xs text-gray-500">{t('statuses.noData')}</div>; 1378 1394 const times = rows.map((r) => toDate(r.time as string)).filter(Boolean) as Date[]; ··· 1610 1626 ); 1611 1627 })()} 1612 1628 <div className="mt-2 text-sm border-t border-gray-100 pt-2"> 1613 - {statsOutside && ( 1629 + {/* Prefer server-side statistics for the range if available */} 1630 + {serverRangeStats?.stats?.temp ? ( 1631 + <div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-2"> 1632 + <div className="bg-amber-50 p-2 rounded"> 1633 + <div className="font-medium text-amber-700">{t('dashboard.daysOver30C')}</div> 1634 + <div className="text-lg">{serverRangeStats.stats.temp.over30?.count ?? '—'} <span className="text-xs text-gray-500">{t('dashboard.of')} {serverRangeStats.totalPeriodDays ?? '—'}</span></div> 1635 + </div> 1636 + <div className="bg-blue-50 p-2 rounded"> 1637 + <div className="font-medium text-blue-700">{t('dashboard.daysUnder0C')}</div> 1638 + <div className="text-lg">{serverRangeStats.stats.temp.under0?.count ?? '—'} <span className="text-xs text-gray-500">{t('dashboard.of')} {serverRangeStats.totalPeriodDays ?? '—'}</span></div> 1639 + </div> 1640 + <div className="bg-rose-50 p-2 rounded"> 1641 + <div className="font-medium text-rose-700">{t('dashboard.highestTemperature')}</div> 1642 + <div className="text-lg">{Number.isFinite(serverRangeStats.stats.temp.max) ? `${serverRangeStats.stats.temp.max.toFixed(1)} °C` : '—'}</div> 1643 + {serverRangeStats.stats.temp.maxDate && (<div className="text-xs text-gray-500">{formatDisplayLocale(new Date(String(serverRangeStats.stats.temp.maxDate) + 'T12:00'), locale)}</div>)} 1644 + </div> 1645 + <div className="bg-indigo-50 p-2 rounded"> 1646 + <div className="font-medium text-indigo-700">{t('dashboard.lowestTemperature')}</div> 1647 + <div className="text-lg">{Number.isFinite(serverRangeStats.stats.temp.min) ? `${serverRangeStats.stats.temp.min.toFixed(1)} °C` : '—'}</div> 1648 + {serverRangeStats.stats.temp.minDate && (<div className="text-xs text-gray-500">{formatDisplayLocale(new Date(String(serverRangeStats.stats.temp.minDate) + 'T12:00'), locale)}</div>)} 1649 + </div> 1650 + <div className="bg-teal-50 p-2 rounded"> 1651 + <div className="font-medium text-teal-700">{t('dashboard.average')}</div> 1652 + <div className="text-lg">{Number.isFinite(serverRangeStats.stats.temp.avg) ? `${serverRangeStats.stats.temp.avg.toFixed(1)} °C` : '—'}</div> 1653 + </div> 1654 + </div> 1655 + ) : ( 1656 + statsOutside && ( 1614 1657 <div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-2"> 1615 1658 <div className="bg-amber-50 p-2 rounded"> 1616 1659 <div className="font-medium text-amber-700">{t('dashboard.daysOver30C')}</div> ··· 1635 1678 <div className="text-lg">{Number.isFinite(avgTempOutside) ? `${avgTempOutside.toFixed(1)} °C` : "—"}</div> 1636 1679 </div> 1637 1680 </div> 1638 - )} 1681 + ))} 1639 1682 {statsFelt && ( 1640 1683 <div className="grid grid-cols-2 gap-2"> 1641 1684 <div className="bg-orange-50 p-2 rounded"> ··· 1783 1826 statsTimes = statsRows.map((r) => toDate(r.time as string)).filter(Boolean) as Date[]; 1784 1827 } 1785 1828 stats = calculateMinMaxForColumn(statsRows, statsTimes, resolvedCol); 1829 + // Prefer server-side wind/gust statistics if available 1830 + if (serverRangeStats?.stats?.wind && (isWind || isGust)) { 1831 + const w = serverRangeStats.stats.wind; 1832 + if (isWind && Number.isFinite(w.max)) { 1833 + stats = { max: w.max, min: NaN as any, maxTime: w.maxDate ? new Date(String(w.maxDate) + 'T12:00') : null, minTime: null }; 1834 + } 1835 + if (isGust && Number.isFinite(w.gustMax)) { 1836 + stats = { max: w.gustMax, min: NaN as any, maxTime: w.gustMaxDate ? new Date(String(w.gustMaxDate) + 'T12:00') : null, minTime: null }; 1837 + } 1838 + } 1786 1839 } 1787 1840 const unit = unitForHeader(col) || ""; 1788 1841 ··· 1845 1898 1846 1899 {/* Regenstatistik */} 1847 1900 {(() => { 1848 - // Verwende die Tageswerte für die Regenstatistik 1849 - // Bei Tagesauflösung sind die Werte bereits korrekt aggregiert 1850 - let statsRainCol = hourlyRainCol || null; 1851 - let statsRows = rows; 1852 - let statsTimes = times; 1853 - 1854 - // Berechne die Regenstatistik 1855 - const rainStats = calculateRainStats(statsRows, statsTimes, statsRainCol); 1901 + // Bevorzugt serverseitige Regenstatistik 1902 + const rainStats = serverRangeStats?.stats?.rain; 1903 + const rainDays = serverRangeStats?.stats?.rainDays; 1856 1904 1857 1905 return ( 1858 1906 <div className="mt-2 text-sm border-t border-gray-100 pt-2"> 1859 1907 <div className="grid grid-cols-3 gap-2"> 1860 1908 <div className="bg-blue-50 p-2 rounded"> 1861 1909 <div className="font-medium text-blue-700">{t('dashboard.daysOver30mm')}</div> 1862 - <div className="text-lg">{rainStats.daysOver30mm} <span className="text-xs text-gray-500">{t('dashboard.of')} {rainStats.totalPeriodDays}</span></div> 1910 + <div className="text-lg">{rainStats?.over30mm?.count ?? '—'} <span className="text-xs text-gray-500">{t('dashboard.of')} {serverRangeStats?.totalPeriodDays ?? '—'}</span></div> 1863 1911 </div> 1864 1912 <div className="bg-gray-50 p-2 rounded"> 1865 1913 <div className="font-medium text-gray-700">{t('dashboard.rainDays')}</div> 1866 - <div className="text-lg">{rainStats.totalDays} <span className="text-xs text-gray-500">{t('dashboard.of')} {rainStats.totalPeriodDays}</span></div> 1914 + <div className="text-lg">{rainDays ?? '—'} <span className="text-xs text-gray-500">{t('dashboard.of')} {serverRangeStats?.totalPeriodDays ?? '—'}</span></div> 1867 1915 </div> 1868 1916 <div className="bg-emerald-50 p-2 rounded"> 1869 1917 <div className="font-medium text-emerald-700">{t('dashboard.total')}</div> 1870 - <div className="text-lg">{totalRain.toFixed(1)} <span className="text-xs text-gray-500">mm</span></div> 1918 + <div className="text-lg">{Number.isFinite(serverRangeStats?.stats?.rain?.total) ? `${serverRangeStats.stats.rain.total.toFixed(1)} ` : `${totalRain.toFixed(1)} `}<span className="text-xs text-gray-500">mm</span></div> 1871 1919 </div> 1872 1920 </div> 1873 1921 </div>
+3
src/constants.js
··· 23 23 STATISTICS: '/api/statistics', 24 24 STATISTICS_UPDATE: '/api/statistics/update', 25 25 STATISTICS_DAILY: '/api/statistics/daily', 26 + // Dashboard/server-side range statistics 27 + STATISTICS_RANGE: '/api/statistics/range', 28 + STATISTICS_CHANNELS: '/api/statistics/channels', 26 29 };
+210
src/lib/statistics.ts
··· 20 20 return rows.filter((r: any) => typeof r.day === 'string' && r.day.startsWith(y)); 21 21 } 22 22 23 + /** Daily aggregate row shape returned by range queries */ 24 + export interface DailyAggregateRow { 25 + day: string; // YYYY-MM-DD 26 + tmax: number | null; 27 + tmin: number | null; 28 + tavg: number | null; 29 + rain_day: number | null; 30 + wind_max: number | null; 31 + gust_max: number | null; 32 + wind_avg: number | null; 33 + } 34 + 23 35 async function queryDailyAggregates(parquetFiles: string[]) { 24 36 if (!parquetFiles.length) return [] as any[]; 25 37 const conn = await getDuckConn(); ··· 100 112 101 113 const reader = await conn.runAndReadAll(sql); 102 114 return reader.getRowObjects(); 115 + } 116 + 117 + /** Query daily aggregates for a specific time range (inclusive) */ 118 + export async function queryDailyAggregatesInRange( 119 + parquetFiles: string[], 120 + start?: Date, 121 + end?: Date 122 + ): Promise<DailyAggregateRow[]> { 123 + if (!parquetFiles.length) return [] as any[]; 124 + const conn = await getDuckConn(); 125 + const qp = parquetFiles.map((p) => p.replace(/\\/g, "/")); 126 + const cols = await discoverMainColumns(qp); 127 + if (!cols.temp) throw new Error("Could not detect outdoor temperature column in main dataset"); 128 + 129 + const arr = '[' + qp.map((p) => `'${p}'`).join(',') + ']'; 130 + 131 + const mergedTempCandidates = [ 132 + ...(cols.tempCandidates || []), 133 + ...(cols.dewCandidates || []), 134 + ...(cols.feelsLikeCandidates || []), 135 + ]; 136 + if (cols.temp && !mergedTempCandidates.includes(cols.temp)) mergedTempCandidates.unshift(cols.temp); 137 + if (cols.dew && !mergedTempCandidates.includes(cols.dew)) mergedTempCandidates.push(cols.dew); 138 + if (cols.feelsLike && !mergedTempCandidates.includes(cols.feelsLike)) mergedTempCandidates.push(cols.feelsLike); 139 + const tempExprList = (mergedTempCandidates.length ? mergedTempCandidates : (cols.temp ? [cols.temp] : [])) 140 + .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 141 + const tExpr = tempExprList.length ? `COALESCE(${tempExprList.join(', ')})` : 'NULL'; 142 + 143 + const rainDailyExprList = (cols.dailyRainCandidates.length ? cols.dailyRainCandidates : []) 144 + .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 145 + const rainHourlyExprList = (cols.hourlyRainCandidates.length ? cols.hourlyRainCandidates : []) 146 + .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 147 + const rainGenericExprList = (cols.genericRainCandidates.length ? cols.genericRainCandidates : []) 148 + .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 149 + const rainDailyExpr = rainDailyExprList.length ? `COALESCE(${rainDailyExprList.join(', ')})` : 'NULL'; 150 + const rainHourlyExpr = rainHourlyExprList.length ? `COALESCE(${rainHourlyExprList.join(', ')})` : 'NULL'; 151 + const rainGenericExpr = rainGenericExprList.length ? `COALESCE(${rainGenericExprList.join(', ')})` : 'NULL'; 152 + const windExprList = (cols.windCandidates && cols.windCandidates.length ? cols.windCandidates : (cols.wind ? [cols.wind] : [])) 153 + .map((c) => speedExprFor(c)); 154 + const gustExprList = (cols.gustCandidates && cols.gustCandidates.length ? cols.gustCandidates : (cols.gust ? [cols.gust] : [])) 155 + .map((c) => speedExprFor(c)); 156 + const windExpr = windExprList.length ? `COALESCE(${windExprList.join(', ')})` : 'NULL'; 157 + const gustExpr = gustExprList.length ? `COALESCE(${gustExprList.join(', ')})` : 'NULL'; 158 + 159 + const whereStart = start ? `ts >= strptime('${formatDuck(start)}', ['%Y-%m-%d %H:%M'])` : '1=1'; 160 + const whereEnd = end ? `ts <= strptime('${formatDuck(end)}', ['%Y-%m-%d %H:%M'])` : '1=1'; 161 + 162 + const sql = ` 163 + WITH src AS ( 164 + SELECT * FROM read_parquet(${arr}, union_by_name=true) 165 + ), 166 + casted AS ( 167 + SELECT ts, 168 + ${tExpr} AS t, 169 + ${rainDailyExpr} AS rain_d, 170 + ${rainHourlyExpr} AS rain_h, 171 + ${rainGenericExpr} AS rain_g, 172 + ${windExpr} AS wind, 173 + ${gustExpr} AS gust 174 + FROM src 175 + WHERE ts IS NOT NULL AND ${whereStart} AND ${whereEnd} 176 + ), 177 + daily AS ( 178 + SELECT 179 + date_trunc('day', ts) AS d, 180 + max(t) AS tmax, 181 + min(t) AS tmin, 182 + avg(t) AS tavg, 183 + max(rain_d) AS rdaily, 184 + sum(rain_h) AS rhourly, 185 + sum(rain_g) AS rgeneric, 186 + max(wind) AS wind_max, 187 + max(gust) AS gust_max, 188 + avg(wind) AS wind_avg 189 + FROM casted 190 + GROUP BY 1 191 + ) 192 + SELECT strftime(d, '%Y-%m-%d') AS day, 193 + tmax, tmin, tavg, 194 + COALESCE(rdaily, rhourly, rgeneric) AS rain_day, 195 + wind_max, gust_max, wind_avg 196 + FROM daily 197 + ORDER BY day; 198 + `; 199 + 200 + const reader = await conn.runAndReadAll(sql); 201 + return reader.getRowObjects() as unknown as DailyAggregateRow[]; 202 + } 203 + 204 + function formatDuck(d: Date) { 205 + const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n)); 206 + const yyyy = d.getFullYear(); 207 + const mm = pad2(d.getMonth() + 1); 208 + const dd = pad2(d.getDate()); 209 + const hh = pad2(d.getHours()); 210 + const mi = pad2(d.getMinutes()); 211 + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; 212 + } 213 + 214 + export function computeStatsFromDaily(rows: DailyAggregateRow[]): { 215 + temp: YearStats["temperature"]; rain: YearStats["precipitation"]; wind: YearStats["wind"]; rainDays: number; 216 + } { 217 + const toNum = (v: any): number | null => { 218 + if (v == null) return null; 219 + if (typeof v === 'number') return Number.isFinite(v) ? v : null; 220 + const s = String(v).trim().replace('−', '-').replace(',', '.').replace(/[^0-9+\-\.]/g, ''); 221 + const n = Number(s); 222 + return Number.isFinite(n) ? n : null; 223 + }; 224 + 225 + let tMax = Number.NEGATIVE_INFINITY; let tMaxDate: string | null = null; 226 + let tMin = Number.POSITIVE_INFINITY; let tMinDate: string | null = null; 227 + let tAvgSum = 0; let tAvgCnt = 0; 228 + const over30: { date: string; value: number }[] = []; 229 + const over25: { date: string; value: number }[] = []; 230 + const over20: { date: string; value: number }[] = []; 231 + const under0: { date: string; value: number }[] = []; 232 + const under10: { date: string; value: number }[] = []; 233 + 234 + let rainTotal = 0; let rainCnt = 0; let rainMax = Number.NEGATIVE_INFINITY; let rainMaxDate: string | null = null; 235 + let rainMin = Number.POSITIVE_INFINITY; let rainMinDate: string | null = null; 236 + const rainOver20: { date: string; value: number }[] = []; 237 + const rainOver30: { date: string; value: number }[] = []; 238 + let rainDays = 0; 239 + 240 + let windMax = Number.NEGATIVE_INFINITY; let windMaxDate: string | null = null; 241 + let gustMax = Number.NEGATIVE_INFINITY; let gustMaxDate: string | null = null; 242 + let windAvgSum = 0; let windAvgCnt = 0; 243 + 244 + for (const r of rows) { 245 + const d = r.day; 246 + const tx = toNum(r.tmax); 247 + const tn = toNum(r.tmin); 248 + const ta = toNum(r.tavg); 249 + if (tx !== null) { 250 + if (tx > tMax) { tMax = tx; tMaxDate = d; } 251 + if (tx > 30) over30.push({ date: d, value: tx }); 252 + if (tx > 25) over25.push({ date: d, value: tx }); 253 + if (tx > 20) over20.push({ date: d, value: tx }); 254 + } 255 + if (tn !== null) { 256 + if (tn < tMin) { tMin = tn; tMinDate = d; } 257 + if (tn < 0) under0.push({ date: d, value: tn }); 258 + if (tn <= -10) under10.push({ date: d, value: tn }); 259 + } 260 + if (ta !== null) { tAvgSum += ta; tAvgCnt++; } 261 + 262 + const rd = toNum(r.rain_day); 263 + if (rd !== null && Number.isFinite(rd)) { 264 + rainCnt++; 265 + if (rd > 0) rainDays++; 266 + rainTotal += rd; 267 + if (rd > rainMax) { rainMax = rd; rainMaxDate = d; } 268 + if (rd < rainMin) { rainMin = rd; rainMinDate = d; } 269 + if (rd >= 20) rainOver20.push({ date: d, value: rd }); 270 + if (rd >= 30) rainOver30.push({ date: d, value: rd }); 271 + } 272 + 273 + const wmx = toNum(r.wind_max); 274 + const gmx = toNum(r.gust_max); 275 + const wav = toNum(r.wind_avg); 276 + if (wmx !== null && wmx > windMax) { windMax = wmx; windMaxDate = d; } 277 + if (gmx !== null && gmx > gustMax) { gustMax = gmx; gustMaxDate = d; } 278 + if (wav !== null) { windAvgSum += wav; windAvgCnt++; } 279 + } 280 + 281 + const temp = { 282 + max: Number.isFinite(tMax) ? tMax : null, 283 + maxDate: tMaxDate, 284 + min: Number.isFinite(tMin) ? tMin : null, 285 + minDate: tMinDate, 286 + avg: tAvgCnt > 0 ? tAvgSum / tAvgCnt : null, 287 + over30: { count: over30.length, items: over30 }, 288 + over25: { count: over25.length, items: over25 }, 289 + over20: { count: over20.length, items: over20 }, 290 + under0: { count: under0.length, items: under0 }, 291 + under10: { count: under10.length, items: under10 }, 292 + } as YearStats["temperature"]; 293 + 294 + const rain = { 295 + total: rainCnt > 0 && Number.isFinite(rainTotal) ? rainTotal : null, 296 + maxDay: rainCnt > 0 && Number.isFinite(rainMax) ? rainMax : null, 297 + maxDayDate: rainCnt > 0 ? rainMaxDate : null, 298 + minDay: rainCnt > 0 && Number.isFinite(rainMin) ? rainMin : null, 299 + minDayDate: rainCnt > 0 ? rainMinDate : null, 300 + over20mm: { count: rainOver20.length, items: rainOver20 }, 301 + over30mm: { count: rainOver30.length, items: rainOver30 }, 302 + } as YearStats["precipitation"]; 303 + 304 + const wind = { 305 + max: Number.isFinite(windMax) ? windMax : null, 306 + maxDate: windMaxDate, 307 + gustMax: Number.isFinite(gustMax) ? gustMax : null, 308 + gustMaxDate: gustMaxDate, 309 + avg: windAvgCnt > 0 ? windAvgSum / windAvgCnt : null, 310 + } as YearStats["wind"]; 311 + 312 + return { temp, rain, wind, rainDays }; 103 313 } 104 314 105 315 function buildYearAndMonthStats(rows: any[]): StatisticsPayload {