Weather Station / ECOWITT / DNT
0

Configure Feed

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

glitch minute/hour/day

+50 -9
+2 -2
src/app/api/statistics/range/route.ts
··· 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 - const { temp, rain, wind, rainDays } = computeStatsFromDaily(days); 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 - return NextResponse.json({ ok: true, start: startIso, end: endIso, totalPeriodDays, days, stats: { temp, rain, wind, rainDays } }); 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 });
+21 -5
src/components/Dashboard.tsx
··· 151 151 </div> 152 152 </div> 153 153 )} 154 - {statsFelt && ( 154 + {/* Feels-like statistics: prefer server-side channel stats */} 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 - <div className="text-lg">{Number.isFinite(statsFelt.maxTemp) ? `${statsFelt.maxTemp.toFixed(1)} °C` : "—"}</div> 159 - {statsFelt.maxTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.maxTime, locale)}</div>)} 159 + <div className="text-lg">{Number.isFinite(serverChannelStats.stats.feels.max) ? `${serverChannelStats.stats.feels.max.toFixed(1)} °C` : '—'}</div> 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 - <div className="text-lg">{Number.isFinite(statsFelt.minTemp) ? `${statsFelt.minTemp.toFixed(1)} °C` : "—"}</div> 164 - {statsFelt.minTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.minTime, locale)}</div>)} 164 + <div className="text-lg">{Number.isFinite(serverChannelStats.stats.feels.min) ? `${serverChannelStats.stats.feels.min.toFixed(1)} °C` : '—'}</div> 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 + ) : ( 169 + statsFelt && ( 170 + <div className="grid grid-cols-2 gap-2"> 171 + <div className="bg-orange-50 p-2 rounded"> 172 + <div className="font-medium text-orange-700">{t('dashboard.feelsLikeMax')}</div> 173 + <div className="text-lg">{Number.isFinite(statsFelt.maxTemp) ? `${statsFelt.maxTemp.toFixed(1)} °C` : "—"}</div> 174 + {statsFelt.maxTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.maxTime, locale)}</div>)} 175 + </div> 176 + <div className="bg-cyan-50 p-2 rounded"> 177 + <div className="font-medium text-cyan-700">{t('dashboard.feelsLikeMin')}</div> 178 + <div className="text-lg">{Number.isFinite(statsFelt.minTemp) ? `${statsFelt.minTemp.toFixed(1)} °C` : "—"}</div> 179 + {statsFelt.minTime && (<div className="text-xs text-gray-500">{formatDisplayLocale(statsFelt.minTime, locale)}</div>)} 180 + </div> 181 + </div> 182 + ) 167 183 )} 168 184 </div> 169 185 );
+27 -2
src/lib/statistics.ts
··· 30 30 wind_max: number | null; 31 31 gust_max: number | null; 32 32 wind_avg: number | null; 33 + tfmax?: number | null; 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 + // Feels-like expression (optional) 60 + const feelsList = (cols.feelsLikeCandidates && cols.feelsLikeCandidates.length ? cols.feelsLikeCandidates : (cols.feelsLike ? [cols.feelsLike] : [])) 61 + .map((c) => sqlNum('"' + String(c).replace(/"/g, '""') + '"')); 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 + ${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 + max(tf) AS tfmax, 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 + 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 - temp: YearStats["temperature"]; rain: YearStats["precipitation"]; wind: YearStats["wind"]; rainDays: number; 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 + 254 + let feltMax = Number.NEGATIVE_INFINITY; let feltMaxDate: string | null = null; 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 + 293 + const fmx = toNum((r as any).tfmax); 294 + const fmn = toNum((r as any).tfmin); 295 + if (fmx !== null && fmx > feltMax) { feltMax = fmx; feltMaxDate = d; } 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 - return { temp, rain, wind, rainDays }; 330 + const feels = (Number.isFinite(feltMax) || Number.isFinite(feltMin)) ? { 331 + max: Number.isFinite(feltMax) ? feltMax : null, 332 + maxDate: feltMaxDate, 333 + min: Number.isFinite(feltMin) ? feltMin : null, 334 + minDate: feltMinDate, 335 + } : undefined; 336 + 337 + return { temp, rain, wind, feels, rainDays }; 313 338 } 314 339 315 340 function buildYearAndMonthStats(rows: any[]): StatisticsPayload {