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