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)
${rows}
MonthRecord highRecord lowWettest dayWettest monthPeak gust
`; } 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; }