Redesign dashboard: tabs, insights, rain, sun/moon, zoomable charts

- Tab bar (bottom nav on phones) splitting Now / Charts / Rain / Records / Insights
- Unified tiles with vs-yesterday deltas, hero 24h trace, wind compass, today hi/lo
- Highlights chips, sun & moon card, station health card
- Charts: min/max envelope for long ranges (server-side bucketing), pinch/wheel zoom,
  pressure tendency, indoor vs outdoor, clear-sky solar overlay
- Rain tab: daily + cumulative, monthly totals; calendar heatmaps (temp/rain/gust)
- Insights: period trends, station normals, diurnal range, coverage, seasonal wind roses
- Server: /api/station/daily, history ?points= bucketing, 2d range, daily avg + wind
  histogram aggregates; fix dry days never recording rain=0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:25:08 -04:00
1 parent a97d104d14
commit 1ad2dbb860
4 files changed
+853 -83

No files matched your search

+73 -5
View File
@@ -232,12 +232,22 @@ function applyReadingToRecords(obs) {
if (obs[k] != null && mergeExtreme(day, field, kind, Number(obs[k]), obs.dateutc)) changed = true;
}
}
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && !(Number(obs.dailyrainin) <= (day.rain || 0))) {
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && (day.rain == null || Number(obs.dailyrainin) > day.rain)) {
day.rain = Number(obs.dailyrainin); changed = true;
}
return changed;
}
// Non-idempotent aggregates (sum/count/direction histogram). Applied once per saved reading;
// a rebuild recomputes them from the raw files instead of adding on top.
function addAggregates(agg, obs) {
if (Number.isFinite(Number(obs.tempf)) && obs.tempf != null) { agg.tsum = (agg.tsum || 0) + Number(obs.tempf); agg.tn = (agg.tn || 0) + 1; }
if (obs.winddir != null && Number.isFinite(Number(obs.winddir)) && Number(obs.windspeedmph) > 0.5) {
const wd = agg.wd || (agg.wd = new Array(16).fill(0));
wd[Math.round(Number(obs.winddir) / 22.5) % 16]++;
}
}
function saveRecordsStore() {
if (!recordsDirty) return;
try {
@@ -248,9 +258,10 @@ function saveRecordsStore() {
}
function updateRecordsFromReading(obs) {
const before = (recordsStore.days[stationLocalDay(obs)] || {}).n || 0;
applyReadingToRecords(obs);
recordsStore.days[stationLocalDay(obs)].n = before + 1;
const day = recordsStore.days[stationLocalDay(obs)];
day.n = (day.n || 0) + 1;
addAggregates(day, obs);
recordsDirty = true;
}
@@ -259,6 +270,7 @@ function rebuildRecordsFromFiles() {
let files;
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
const counts = {};
const fresh = {};
let scanned = 0;
for (const f of files) {
try {
@@ -271,12 +283,20 @@ function rebuildRecordsFromFiles() {
applyReadingToRecords(r);
const k = stationLocalDay(r);
counts[k] = (counts[k] || 0) + 1;
addAggregates(fresh[k] || (fresh[k] = {}), r);
scanned++;
}
} catch {}
}
// Reading counts come from the files; keep the larger so pruned history isn't forgotten.
for (const [k, n] of Object.entries(counts)) recordsStore.days[k].n = Math.max(recordsStore.days[k].n || 0, n);
for (const [k, n] of Object.entries(counts)) {
const day = recordsStore.days[k];
day.n = Math.max(day.n || 0, n);
// Replace aggregates only when the files hold at least as many samples as the store did
const f = fresh[k];
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
if (f.wd && (!day.wd || f.wd.reduce((a, b) => a + b, 0) >= day.wd.reduce((a, b) => a + b, 0))) day.wd = f.wd;
}
recordsDirty = true;
saveRecordsStore();
return scanned;
@@ -1309,12 +1329,42 @@ app.get('/dashboard/settings', requireDashboardAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
});
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for
// extremes, and a speed-weighted vector mean for wind direction.
const BUCKET_AVG = ['tempf', 'feelsLike', 'humidity', 'dewPoint', 'windspeedmph', 'baromrelin', 'baromabsin', 'uv', 'solarradiation', 'tempinf', 'humidityin'];
const BUCKET_MAX = ['windgustmph', 'rainratein', 'dailyrainin', 'maxdailygust'];
function bucketReadings(readings, points) {
const t0 = readings[0].dateutc, span = readings[readings.length - 1].dateutc - t0 || 1;
const size = span / points;
const buckets = new Map();
for (const r of readings) {
const i = Math.min(points - 1, Math.floor((r.dateutc - t0) / size));
let b = buckets.get(i);
if (!b) { b = { n: 0, tsum: 0, sums: {}, cnt: {}, max: {}, tmin: Infinity, tmax: -Infinity, vx: 0, vy: 0 }; buckets.set(i, b); }
b.n++; b.tsum += r.dateutc;
for (const k of BUCKET_AVG) if (r[k] != null) { b.sums[k] = (b.sums[k] || 0) + r[k]; b.cnt[k] = (b.cnt[k] || 0) + 1; }
for (const k of BUCKET_MAX) if (r[k] != null && (b.max[k] == null || r[k] > b.max[k])) b.max[k] = r[k];
if (r.tempf != null) { if (r.tempf < b.tmin) b.tmin = r.tempf; if (r.tempf > b.tmax) b.tmax = r.tempf; }
if (r.winddir != null) { const w = Math.max(r.windspeedmph || 0, 0.1) * Math.PI / 180; const a = r.winddir * Math.PI / 180; b.vx += Math.sin(a) * (r.windspeedmph || 0.1); b.vy += Math.cos(a) * (r.windspeedmph || 0.1); }
}
return [...buckets.entries()].sort(([a], [b]) => a - b).map(([, b]) => {
const o = { dateutc: Math.round(b.tsum / b.n), n: b.n };
for (const k of BUCKET_AVG) if (b.cnt[k]) o[k] = +(b.sums[k] / b.cnt[k]).toFixed(k === 'baromrelin' || k === 'baromabsin' ? 3 : 2);
for (const k of BUCKET_MAX) if (b.max[k] != null) o[k] = b.max[k];
if (b.tmin !== Infinity) { o.tempMin = b.tmin; o.tempMax = b.tmax; }
if (b.vx || b.vy) o.winddir = Math.round((Math.atan2(b.vx, b.vy) * 180 / Math.PI + 360) % 360);
return o;
});
}
// Station history API
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
const range = req.query.range || '24h';
const rangeMs = rangeMap[range] || rangeMap['24h'];
const readings = loadStationReadings(rangeMs);
const points = parseInt(req.query.points);
if (points >= 50 && readings.length > points) return res.json(bucketReadings(readings, points));
res.json(readings);
});
@@ -1419,6 +1469,24 @@ app.get('/api/station/records', requireDashboardAuth, (_req, res) => {
res.json(deriveRecords());
});
// Per-day summaries (extremes, average temp, rain, wind-direction histogram) for charts/insights
app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.days) || 0, 0), 5000);
let entries = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
if (limit) entries = entries.slice(-limit);
res.json(entries.map(([d, v]) => ({
d, n: v.n || 0,
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,
rain: v.rain ?? null, rate: v.rate?.[0] ?? null,
uv: v.uv?.[0] ?? null, solar: v.solar?.[0] ?? null,
pmax: v.pmax?.[0] ?? null, pmin: v.pmin?.[0] ?? null,
hmax: v.hmax?.[0] ?? null, hmin: v.hmin?.[0] ?? null,
wd: v.wd || null,
})));
});
// Re-scan raw readings into the records store (merge only; never lowers a record)
app.post('/api/station/records/rebuild', requireDashboardAuth, (_req, res) => {
const scanned = rebuildRecordsFromFiles();