diff --git a/app/public/dashboard-ui.css b/app/public/dashboard-ui.css
index 1517609..c599ff4 100644
--- a/app/public/dashboard-ui.css
+++ b/app/public/dashboard-ui.css
@@ -122,3 +122,12 @@ a { color: var(--ui); }
.chart-ctl select { font: inherit; font-size: 0.76rem; padding: 0.25rem 0.4rem; min-height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); }
@media (pointer: coarse) { .chart-ctl select { font-size: 16px; min-height: 40px; } }
.chart-card .chart-title { flex-wrap: wrap; row-gap: 0.4rem; }
+
+/* Month / year record cards: the period is the headline */
+.record-period { font-size: 0.92rem; font-weight: 800; color: var(--ink); letter-spacing: -0.005em; margin-top: 0.1rem; }
+.rec-sub { text-transform: none; letter-spacing: 0; font-weight: 400; opacity: .75; }
+.rec-cal td { vertical-align: top; line-height: 1.25; }
+.rec-cal td b { display: block; font-variant-numeric: tabular-nums; }
+.rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; }
+
+.rec-cal tr.empty { opacity: .4; }
diff --git a/app/public/dashboard.html b/app/public/dashboard.html
index 73e8410..275bfb6 100644
--- a/app/public/dashboard.html
+++ b/app/public/dashboard.html
@@ -1238,11 +1238,41 @@ function renderRecordPeriods(d, ri) {
['Windiest Day', nv(n.windiestDay, v => v.toFixed(1) + ' mph'), n.windiestDay],
['Wettest Day', nv(n.wettestDay, v => v.toFixed(2) + '"'), n.wettestDay],
].map(([l, v, o]) => ri(l, v, o ? dayDate(o.day) : null)).join('') + '';
- if (d.wettestMonth || d.wettestYear) {
- html += '
Wettest periods
'
- + (d.wettestMonth ? ri('Wettest Month', d.wettestMonth.rain.toFixed(2) + '"', dayDate(d.wettestMonth.month + '-15')) : '')
- + (d.wettestYear ? ri('Wettest Year', d.wettestYear.rain.toFixed(2) + '"', dayDate(d.wettestYear.year + '-07-01')) : '')
- + '
';
+ // Months & years: the period itself (e.g. "October 2026") is the headline of each card
+ const now = new Date();
+ const curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'), curYear = String(now.getFullYear());
+ const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
+ const rp = (label, rec, fmtVal, isMonth, waitMsg) => {
+ if (!rec) return `${label}
—
${waitMsg || 'Not enough data yet'}
`;
+ const current = rec.key === (isMonth ? curMonth : curYear);
+ return `${label}
${fmtVal(rec.value)}
+
${isMonth ? longMonth(rec.key) : rec.key}
${rec.days} day${rec.days === 1 ? '' : 's'} recorded${rec.partial ? ' · partial' : ''}
`;
+ };
+ const P = JSON.parse(JSON.stringify(d.periods || { months: {}, years: {} }));
+ // An extreme and its opposite landing on the same period just means there's nothing to compare yet
+ const same = (a, b) => a && b && a.key === b.key;
+ const waitM = 'Needs another full month', waitY = 'Needs another year of data';
+ let wM = {}, wY = {};
+ if (same(P.months.hottest, P.months.coldest)) { P.months.coldest = null; wM.coldest = waitM; }
+ if (same(P.months.wettest, P.months.driest)) { P.months.driest = null; wM.driest = waitM; }
+ if (same(P.years.hottest, P.years.coldest)) { P.years.coldest = null; wY.coldest = waitY; }
+ const deg = (v) => v.toFixed(1) + '°F', inch = (v) => v.toFixed(2) + '"', mph = (v) => v.toFixed(0) + ' mph';
+ html += 'Months (avg temp = mean of all readings; partial months are flagged)
'
+ + rp('Wettest Month', P.months.wettest, inch, true) + rp('Driest Month', P.months.driest, inch, true, wM.driest)
+ + rp('Hottest Month', P.months.hottest, deg, true) + rp('Coldest Month', P.months.coldest, deg, true, wM.coldest)
+ + rp('Windiest Month', P.months.windiest, mph, true) + '
';
+ html += 'Years
'
+ + rp('Wettest Year', P.years.wettest, inch, false) + rp('Hottest Year', P.years.hottest, deg, false)
+ + rp('Coldest Year', P.years.coldest, deg, false, wY.coldest) + rp('Windiest Year', P.years.windiest, mph, false) + '
';
+
+ // Records for each calendar month, whichever year they happened in
+ if (d.calendarMonths && d.calendarMonths.some(c => c.years)) {
+ const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
+ const cell = (x, fmtV) => x ? `${fmtV(x.value)}${x.day ? new Date(x.day + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : ''}` : '—';
+ const rows = d.calendarMonths.map((c, i) => `| ${mn(i)}${c.years ? c.years + ' year' + (c.years > 1 ? 's' : '') : 'no data'} |
+ ${cell(c.hi, deg)} | ${cell(c.lo, deg)} | ${cell(c.wettestDay, inch)} |
+ ${c.wettestTotal ? `${inch(c.wettestTotal.value)}${longMonth(c.wettestTotal.month)}` : '—'} | ${cell(c.gust, mph)} |
`).join('');
+ html += `Records by calendar month (any year)
`;
}
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
const row = (label, a) => `| ${label} | ${a.hi != null ? a.hi.toFixed(1) : '—'} | ${a.lo != null ? a.lo.toFixed(1) : '—'} | ${a.gust != null ? a.gust.toFixed(0) : '—'} | ${a.rain.toFixed(2)} | ${a.days} |
`;
diff --git a/app/server.js b/app/server.js
index 662578c..596191c 100644
--- a/app/server.js
+++ b/app/server.js
@@ -452,13 +452,18 @@ function deriveRecords() {
const agg = (keyFn) => {
const g = {};
for (const [d, v] of days) {
- const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null });
+ const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null, tsum: 0, tn: 0 });
a.days++; a.rain += v.rain || 0;
+ if (v.tn) { a.tsum += v.tsum; a.tn += v.tn; }
if (v.hi && (a.hi == null || v.hi[0] > a.hi)) a.hi = v.hi[0];
if (v.lo && (a.lo == null || v.lo[0] < a.lo)) a.lo = v.lo[0];
if (v.gust && (a.gust == null || v.gust[0] > a.gust)) a.gust = v.gust[0];
}
- for (const a of Object.values(g)) a.rain = +a.rain.toFixed(2);
+ for (const a of Object.values(g)) {
+ a.rain = +a.rain.toFixed(2);
+ a.avg = a.tn ? +(a.tsum / a.tn).toFixed(1) : null;
+ delete a.tsum; delete a.tn;
+ }
return g;
};
const byYear = agg(d => d.slice(0, 4));
@@ -468,6 +473,55 @@ function deriveRecords() {
out.months = ym;
out.wettestMonth = ym.reduce((b, m) => (!b || m.rain > b.rain ? m : b), null);
out.wettestYear = out.years.reduce((b, y) => (!b || y.rain > b.rain ? y : b), null);
+
+ // Month / year records (partial periods are flagged so "coldest month" can't be a 2-week stub unnoticed)
+ const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
+ const mk = (p, key, val, total) => ({ key, value: val, days: p.days, partial: p.days < total * 0.9 });
+ // preferComplete: averages / "driest" are only fair over whole periods, so rank complete ones first and
+ // fall back to partial ones only when no complete period exists yet. Totals and extremes stay valid when partial.
+ const pick = (list, keyName, valFn, dir, totalFn, preferComplete) => {
+ const rank = (onlyComplete) => {
+ let best = null;
+ for (const p of list) {
+ const v = valFn(p); if (v == null) continue;
+ const total = totalFn(p[keyName]);
+ if (onlyComplete && p.days < total * 0.9) continue;
+ if (!best || (dir === 'max' ? v > best.value : v < best.value)) best = mk(p, p[keyName], v, total);
+ }
+ return best;
+ };
+ return (preferComplete && rank(true)) || rank(false);
+ };
+ const mPick = (valFn, dir, preferComplete) => pick(ym, 'month', valFn, dir, dim, preferComplete);
+ const yPick = (valFn, dir, preferComplete) => pick(out.years, 'year', valFn, dir, () => 365, preferComplete);
+ out.periods = {
+ months: {
+ wettest: mPick(p => p.rain, 'max'), driest: mPick(p => p.rain, 'min', true),
+ hottest: mPick(p => p.avg, 'max', true), coldest: mPick(p => p.avg, 'min', true), windiest: mPick(p => p.gust, 'max'),
+ },
+ years: {
+ wettest: yPick(p => p.rain, 'max'), hottest: yPick(p => p.avg, 'max', true),
+ coldest: yPick(p => p.avg, 'min', true), windiest: yPick(p => p.gust, 'max'),
+ },
+ };
+
+ // Records for each calendar month across every year ("record July", "record January", …)
+ const cal = Array.from({ length: 12 }, () => ({ hi: null, lo: null, wettestDay: null, gust: null, wettestTotal: null, years: new Set() }));
+ const monthRain = {};
+ for (const [d, v] of days) {
+ const m = +d.slice(5, 7) - 1, c = cal[m];
+ c.years.add(d.slice(0, 4));
+ if (v.hi && (!c.hi || v.hi[0] > c.hi.value)) c.hi = { value: v.hi[0], day: d };
+ if (v.lo && (!c.lo || v.lo[0] < c.lo.value)) c.lo = { value: v.lo[0], day: d };
+ if (v.rain != null && v.rain > 0 && (!c.wettestDay || v.rain > c.wettestDay.value)) c.wettestDay = { value: v.rain, day: d };
+ if (v.gust && (!c.gust || v.gust[0] > c.gust.value)) c.gust = { value: v.gust[0], day: d };
+ monthRain[d.slice(0, 7)] = (monthRain[d.slice(0, 7)] || 0) + (v.rain || 0);
+ }
+ for (const [ymKey, total] of Object.entries(monthRain)) {
+ const c = cal[+ymKey.slice(5, 7) - 1];
+ if (total > 0 && (!c.wettestTotal || total > c.wettestTotal.value)) c.wettestTotal = { value: +total.toFixed(2), month: ymKey };
+ }
+ out.calendarMonths = cal.map((c, i) => ({ month: i + 1, years: c.years.size, hi: c.hi, lo: c.lo, wettestDay: c.wettestDay, gust: c.gust, wettestTotal: c.wettestTotal }));
return out;
}