···
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);
70
70
+
const { temp, rain, wind, feels, 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 } });
76
76
+
return NextResponse.json({ ok: true, start: startIso, end: endIso, totalPeriodDays, days, stats: { temp, feels, 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 });
···
151
151
</div>
152
152
</div>
153
153
)}
154
154
-
{statsFelt && (
154
154
+
{/* Feels-like statistics: prefer server-side channel stats */}
155
155
+
{serverChannelStats?.stats?.feels ? (
155
156
<div className="grid grid-cols-2 gap-2">
156
157
<div className="bg-orange-50 p-2 rounded">
157
158
<div className="font-medium text-orange-700">{t('dashboard.feelsLikeMax')}</div>
158
158
-
<div className="text-lg">{Number.isFinite(statsFelt.maxTemp) ? `${statsFelt.maxTemp.toFixed(1)} °C` : "—"}</div>
159
159
-
{statsFelt.maxTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.maxTime, locale)}</div>)}
159
159
+
<div className="text-lg">{Number.isFinite(serverChannelStats.stats.feels.max) ? `${serverChannelStats.stats.feels.max.toFixed(1)} °C` : '—'}</div>
160
160
+
{serverChannelStats.stats.feels.maxDate && (<div className="text-xs text-gray-500">{formatDisplayLocale(new Date(String(serverChannelStats.stats.feels.maxDate) + 'T12:00'), locale)}</div>)}
160
161
</div>
161
162
<div className="bg-cyan-50 p-2 rounded">
162
163
<div className="font-medium text-cyan-700">{t('dashboard.feelsLikeMin')}</div>
163
163
-
<div className="text-lg">{Number.isFinite(statsFelt.minTemp) ? `${statsFelt.minTemp.toFixed(1)} °C` : "—"}</div>
164
164
-
{statsFelt.minTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.minTime, locale)}</div>)}
164
164
+
<div className="text-lg">{Number.isFinite(serverChannelStats.stats.feels.min) ? `${serverChannelStats.stats.feels.min.toFixed(1)} °C` : '—'}</div>
165
165
+
{serverChannelStats.stats.feels.minDate && (<div className="text-xs text-gray-500">{formatDisplayLocale(new Date(String(serverChannelStats.stats.feels.minDate) + 'T12:00'), locale)}</div>)}
165
166
</div>
166
167
</div>
168
168
+
) : (
169
169
+
statsFelt && (
170
170
+
<div className="grid grid-cols-2 gap-2">
171
171
+
<div className="bg-orange-50 p-2 rounded">
172
172
+
<div className="font-medium text-orange-700">{t('dashboard.feelsLikeMax')}</div>
173
173
+
<div className="text-lg">{Number.isFinite(statsFelt.maxTemp) ? `${statsFelt.maxTemp.toFixed(1)} °C` : "—"}</div>
174
174
+
{statsFelt.maxTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.maxTime, locale)}</div>)}
175
175
+
</div>
176
176
+
<div className="bg-cyan-50 p-2 rounded">
177
177
+
<div className="font-medium text-cyan-700">{t('dashboard.feelsLikeMin')}</div>
178
178
+
<div className="text-lg">{Number.isFinite(statsFelt.minTemp) ? `${statsFelt.minTemp.toFixed(1)} °C` : "—"}</div>
179
179
+
{statsFelt.minTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.minTime, locale)}</div>)}
180
180
+
</div>
181
181
+
</div>
182
182
+
)
167
183
)}
168
184
</div>
169
185
);
···
30
30
wind_max: number | null;
31
31
gust_max: number | null;
32
32
wind_avg: number | null;
33
33
+
tfmax?: number | null;
34
34
+
tfmin?: number | null;
33
35
}
34
36
35
37
async function queryDailyAggregates(parquetFiles: string[]) {
···
54
56
const tempExprList = (mergedTempCandidates.length ? mergedTempCandidates : (cols.temp ? [cols.temp] : []))
55
57
.map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"'));
56
58
const tExpr = tempExprList.length ? `COALESCE(${tempExprList.join(', ')})` : 'NULL';
59
59
+
// Feels-like expression (optional)
60
60
+
const feelsList = (cols.feelsLikeCandidates && cols.feelsLikeCandidates.length ? cols.feelsLikeCandidates : (cols.feelsLike ? [cols.feelsLike] : []))
61
61
+
.map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"'));
62
62
+
const feelsExpr = feelsList.length ? `COALESCE(${feelsList.join(', ')})` : 'NULL';
57
63
// Build rain expressions per family to support fallback (daily cumulative vs hourly/generic sums)
58
64
const rainDailyExprList = (cols.dailyRainCandidates.length ? cols.dailyRainCandidates : [])
59
65
.map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"'));
···
79
85
casted AS (
80
86
SELECT ts,
81
87
${tExpr} AS t,
88
88
+
${feelsExpr} AS tf,
82
89
${rainDailyExpr} AS rain_d,
83
90
${rainHourlyExpr} AS rain_h,
84
91
${rainGenericExpr} AS rain_g,
···
93
100
max(t) AS tmax,
94
101
min(t) AS tmin,
95
102
avg(t) AS tavg,
103
103
+
max(tf) AS tfmax,
104
104
+
min(tf) AS tfmin,
96
105
max(rain_d) AS rdaily,
97
106
sum(rain_h) AS rhourly,
98
107
sum(rain_g) AS rgeneric,
···
104
113
)
105
114
SELECT strftime(d, '%Y-%m-%d') AS day,
106
115
tmax, tmin, tavg,
116
116
+
tfmax, tfmin,
107
117
COALESCE(rdaily, rhourly, rgeneric) AS rain_day,
108
118
wind_max, gust_max, wind_avg
109
119
FROM daily
···
212
222
}
213
223
214
224
export function computeStatsFromDaily(rows: DailyAggregateRow[]): {
215
215
-
temp: YearStats["temperature"]; rain: YearStats["precipitation"]; wind: YearStats["wind"]; rainDays: number;
225
225
+
temp: YearStats["temperature"]; rain: YearStats["precipitation"]; wind: YearStats["wind"]; feels?: { max: number | null; maxDate: string | null; min: number | null; minDate: string | null }; rainDays: number;
216
226
} {
217
227
const toNum = (v: any): number | null => {
218
228
if (v == null) return null;
···
240
250
let windMax = Number.NEGATIVE_INFINITY; let windMaxDate: string | null = null;
241
251
let gustMax = Number.NEGATIVE_INFINITY; let gustMaxDate: string | null = null;
242
252
let windAvgSum = 0; let windAvgCnt = 0;
253
253
+
254
254
+
let feltMax = Number.NEGATIVE_INFINITY; let feltMaxDate: string | null = null;
255
255
+
let feltMin = Number.POSITIVE_INFINITY; let feltMinDate: string | null = null;
243
256
244
257
for (const r of rows) {
245
258
const d = r.day;
···
276
289
if (wmx !== null && wmx > windMax) { windMax = wmx; windMaxDate = d; }
277
290
if (gmx !== null && gmx > gustMax) { gustMax = gmx; gustMaxDate = d; }
278
291
if (wav !== null) { windAvgSum += wav; windAvgCnt++; }
292
292
+
293
293
+
const fmx = toNum((r as any).tfmax);
294
294
+
const fmn = toNum((r as any).tfmin);
295
295
+
if (fmx !== null && fmx > feltMax) { feltMax = fmx; feltMaxDate = d; }
296
296
+
if (fmn !== null && fmn < feltMin) { feltMin = fmn; feltMinDate = d; }
279
297
}
280
298
281
299
const temp = {
···
309
327
avg: windAvgCnt > 0 ? windAvgSum / windAvgCnt : null,
310
328
} as YearStats["wind"];
311
329
312
312
-
return { temp, rain, wind, rainDays };
330
330
+
const feels = (Number.isFinite(feltMax) || Number.isFinite(feltMin)) ? {
331
331
+
max: Number.isFinite(feltMax) ? feltMax : null,
332
332
+
maxDate: feltMaxDate,
333
333
+
min: Number.isFinite(feltMin) ? feltMin : null,
334
334
+
minDate: feltMinDate,
335
335
+
} : undefined;
336
336
+
337
337
+
return { temp, rain, wind, feels, rainDays };
313
338
}
314
339
315
340
function buildYearAndMonthStats(rows: any[]): StatisticsPayload {