diff --git a/app/public/dashboard-records.js b/app/public/dashboard-records.js
new file mode 100644
index 0000000..2e78ae6
--- /dev/null
+++ b/app/public/dashboard-records.js
@@ -0,0 +1,217 @@
+/* Records tab: plain-language station records. Reads /api/station/records and /api/station/daily. */
+(() => {
+ const root = document.getElementById('recordsRoot');
+ if (!root) return;
+
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const DAY_MS = 86400000;
+ const dayMs = (d) => Date.parse(d + 'T12:00:00Z');
+ const fmtDay = (d) => new Date(dayMs(d)).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
+ const fmtTime = (ts) => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ const localDayKey = (ts) => new Date(ts).toLocaleDateString('en-CA');
+ const today = () => localDayKey(Date.now());
+ const daysBetween = (d) => Math.round((dayMs(today()) - dayMs(d)) / DAY_MS);
+ const agoText = (d) => {
+ const n = daysBetween(d);
+ if (n <= 0) return 'today';
+ if (n === 1) return 'yesterday';
+ if (n < 14) return n + ' days ago';
+ if (n < 60) return Math.round(n / 7) + ' weeks ago';
+ return Math.round(n / 30.4) + ' months ago';
+ };
+
+ // Plain-English labels for numbers people can't judge on their own
+ const gustLabel = (v) => v < 19 ? 'Breezy' : v < 32 ? 'Windy' : v < 47 ? 'Strong wind' : v < 64 ? 'Gale' : v < 74 ? 'Storm force' : 'Hurricane force';
+ const uvLabel = (v) => v < 3 ? 'Low' : v < 6 ? 'Moderate' : v < 8 ? 'High' : v < 11 ? 'Very high' : 'Extreme';
+ const rateLabel = (v) => v < 0.1 ? 'Light rain' : v < 0.3 ? 'Moderate rain' : v < 2 ? 'Heavy rain' : 'Violent downpour';
+
+ const deg = (v) => v.toFixed(1) + '°F';
+ const inch = (v) => v.toFixed(2) + '"';
+ const mph = (v) => v.toFixed(1) + ' mph';
+
+ // Each record: where it comes from, how to say it, and what it means to someone new to weather data.
+ // field/kind drive the "runner-up" line and "today vs record" line from the per-day table.
+ const RECORDS = [
+ { theme: 'Temperature', icon: '🔥', title: 'Hottest', why: 'The highest air temperature your station has measured.', field: 'hi', kind: 'max', fmt: deg, today: 'high' },
+ { theme: 'Temperature', icon: '🥶', title: 'Coldest', why: 'The lowest air temperature your station has measured.', field: 'lo', kind: 'min', fmt: deg, today: 'low' },
+ { theme: 'Temperature', icon: '🌤', title: 'Coolest daytime high', why: 'The day that never warmed up: its high was the lowest of any day.', field: 'hi', kind: 'min', fmt: deg, day: (r) => r.notable?.coolestHigh },
+ { theme: 'Temperature', icon: '🌙', title: 'Warmest night', why: 'The night that never cooled off: its low was the highest of any day.', field: 'lo', kind: 'max', fmt: deg, day: (r) => r.notable?.warmestLow },
+
+ { theme: 'Rain', icon: '🌧', title: 'Wettest day', why: 'The most rain that fell in a single day (midnight to midnight).', field: 'rain', kind: 'max', fmt: inch, day: (r) => r.notable?.wettestDay, today: 'rain' },
+ { theme: 'Rain', icon: '⛈', title: 'Heaviest downpour', why: 'How fast rain fell at its hardest moment, in inches per hour.', field: 'rate', kind: 'max', fmt: (v) => v.toFixed(2) + ' in/hr', tag: rateLabel, val: (r) => r.highRainRate, ts: (r) => r.highRainRateDate },
+
+ { theme: 'Wind', icon: '💨', title: 'Strongest gust', why: 'The fastest short burst of wind. Gusts last only a few seconds.', field: 'gust', kind: 'max', fmt: mph, tag: gustLabel, val: (r) => r.highGust, ts: (r) => r.highGustDate, today: 'gust' },
+
+ { theme: 'Sun', icon: '☀️', title: 'Strongest sunshine', why: 'Peak sunlight power. Around 1,000 W/m² is about the most you get on a clear summer day.', field: 'solar', kind: 'max', fmt: (v) => Math.round(v).toLocaleString() + ' W/m²', val: (r) => r.highSolar, ts: (r) => r.highSolarDate },
+ { theme: 'Sun', icon: '🧴', title: 'Highest UV index', why: 'How strong the sun\'s burning rays were. 3 and up is when sunscreen starts to matter.', field: 'uv', kind: 'max', fmt: (v) => String(Math.round(v)), tag: uvLabel, val: (r) => r.highUV, ts: (r) => r.highUVDate },
+
+ { theme: 'Air pressure', icon: '📈', title: 'Highest pressure', why: 'High pressure usually means calm, clear weather.', field: 'pmax', kind: 'max', fmt: (v) => v.toFixed(2) + ' inHg', val: (r) => r.highPressure, ts: (r) => r.highPressureDate },
+ { theme: 'Air pressure', icon: '📉', title: 'Lowest pressure', why: 'Low pressure usually comes with storms and rain. The lower it goes, the stronger the storm.', field: 'pmin', kind: 'min', fmt: (v) => v.toFixed(2) + ' inHg', val: (r) => r.lowPressure, ts: (r) => r.lowPressureDate },
+
+ { theme: 'Humidity', icon: '💧', title: 'Most humid', why: 'How much moisture was in the air. 100% means the air can\'t hold any more (fog, dew, or a muggy day).', field: 'hmax', kind: 'max', fmt: (v) => Math.round(v) + '%', val: (r) => r.highHumidity, ts: (r) => r.highHumidityDate },
+ { theme: 'Humidity', icon: '🏜', title: 'Driest air', why: 'The lowest moisture in the air. Under 30% feels dry: static shocks and dry skin.', field: 'hmin', kind: 'min', fmt: (v) => Math.round(v) + '%', val: (r) => r.lowHumidity, ts: (r) => r.lowHumidityDate },
+ ];
+
+ // Resolve a record definition to concrete numbers/dates (or null when the station hasn't measured it yet)
+ function resolve(def, r) {
+ if (def.day) {
+ const o = def.day(r);
+ return o ? { value: o.value, day: o.day, ts: null } : null;
+ }
+ const value = def.val ? def.val(r) : (def.field === 'hi' ? r.highTemp : def.field === 'lo' ? r.lowTemp : null);
+ const ts = def.ts ? def.ts(r) : (def.field === 'hi' ? r.highTempDate : r.lowTempDate);
+ if (value == null || !ts) return null;
+ return { value, ts, day: localDayKey(ts) };
+ }
+
+ function runnersUp(def, daily, bestDay) {
+ const rows = daily.filter((d) => d[def.field] != null && d.d !== bestDay);
+ rows.sort((a, b) => (def.kind === 'max' ? b[def.field] - a[def.field] : a[def.field] - b[def.field]));
+ return rows.slice(0, 2);
+ }
+
+ function todayLine(def, rec, daily) {
+ if (!def.today || !daily.length) return '';
+ const row = daily[daily.length - 1];
+ if (row.d !== today()) return '';
+ const key = { high: 'hi', low: 'lo', rain: 'rain', gust: 'gust' }[def.today];
+ const v = row[key];
+ if (v == null) return '';
+ const diff = Math.abs(rec.value - v);
+ const gap = def.fmt === deg ? diff.toFixed(1) + '°' : def.fmt === inch ? diff.toFixed(2) + '"' : diff.toFixed(1) + ' mph';
+ const beyond = def.kind === 'max' ? v >= rec.value : v <= rec.value;
+ const where = beyond ? 'at the record' : (def.kind === 'max' ? gap + ' below the record' : gap + ' above the record');
+ const label = { high: 'Today\'s high so far', low: 'Today\'s low so far', rain: 'Rain so far today', gust: 'Today\'s strongest gust' }[def.today];
+ return `
${label}: ${def.fmt(v)} · ${where}
`;
+ }
+
+ function card(def, r, daily) {
+ const rec = resolve(def, r);
+ if (!rec) return null;
+ const isNew = daysBetween(rec.day) <= 14;
+ const when = rec.ts && def.field !== 'rain'
+ ? `${fmtDay(rec.day)} at ${fmtTime(rec.ts)}`
+ : fmtDay(rec.day);
+ const ru = runnersUp(def, daily, rec.day);
+ const ruLine = ru.length
+ ? `Next closest: ${ru.map((x) => `${def.fmt(x[def.field])} (${new Date(dayMs(x.d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}) `).join(' · ')}
`
+ : '';
+ return `
+
${def.icon} ${def.title} ${isNew ? 'NEW ' : ''}
+
${def.fmt(rec.value)}${def.tag ? `${def.tag(rec.value)} ` : ''}
+
${when} · ${agoText(rec.day)}
+
${def.why}
+ ${todayLine(def, rec, daily)}${ruLine}
+
`;
+ }
+
+ function monthStrip(r) {
+ const have = new Set((r.months || []).map((m) => +m.month.slice(5, 7)));
+ const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ return `${names.map((n, i) => `${n} `).join('')}
`;
+ }
+
+ function intro(r) {
+ const missing = 12 - new Set((r.months || []).map((m) => +m.month.slice(5, 7))).size;
+ return `
+
Your station's records
+
A record is the most extreme weather your station has measured. Your station has been recording for ${r.daysRecorded} days (since ${fmtDay(r.firstDay)}), so these are records so far . They will change as the seasons go by.
+ ${monthStrip(r)}
+
${missing > 0 ? `Highlighted months have data. The other ${missing} will fill in over time, so winter cold and summer heat records are not final until your station has been through those seasons. ` : ''}A NEW badge means the record was set in the last 14 days.
+
`;
+ }
+
+ function monthTable(r) {
+ const months = r.months || [];
+ if (!months.length) return '';
+ const P = r.periods?.months || {};
+ const chips = {};
+ if (months.length >= 2) {
+ const add = (rec, chip) => { if (rec) (chips[rec.key] = chips[rec.key] || []).push(chip); };
+ add(P.hottest, '🔥 hottest month'); add(P.coldest, '❄️ coldest month');
+ add(P.wettest, '🌧 wettest month'); add(P.driest, '🏜 driest month');
+ }
+ const lo = Math.min(...months.map((m) => m.lo).filter((v) => v != null));
+ const hi = Math.max(...months.map((m) => m.hi).filter((v) => v != null));
+ const maxRain = Math.max(0.01, ...months.map((m) => m.rain));
+ const span = Math.max(1, hi - lo);
+ const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
+ const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
+ const LIMIT = 12;
+ const rows = [...months].reverse().map((m, idx) => {
+ const total = dim(m.month), partial = m.days < total * 0.9;
+ const left = m.lo != null ? ((m.lo - lo) / span) * 100 : 0;
+ const width = m.lo != null && m.hi != null ? Math.max(3, ((m.hi - m.lo) / span) * 100) : 0;
+ return `= LIMIT ? ' class="rec-extra" hidden' : ''}>
+ ${longMonth(m.month)} ${(chips[m.month] || []).map((c) => `${c} `).join('')}${partial ? `${m.days} of ${total} days so far ` : ''}
+ ${m.avg != null ? m.avg.toFixed(1) + '°F' : '—'}
+ ${m.lo != null ? Math.round(m.lo) : '—'}° to ${m.hi != null ? Math.round(m.hi) : '—'}°
+ ${m.rain.toFixed(2)}"
+ ${m.gust != null ? Math.round(m.gust) + ' mph' : '—'} `;
+ }).join('');
+ return `Month by month
+ How each month compared. The temperature bar shows the coldest to hottest reading that month, on one shared scale.
+ ${months.length > LIMIT ? `Show all ${months.length} months ` : ''}`;
+ }
+
+ function yearBlock(r) {
+ const years = r.years || [];
+ if (!years.length) return '';
+ if (years.length === 1) {
+ const y = years[0];
+ return `${y.year} so far
+ ${y.days} days recorded: hottest ${y.hi != null ? deg(y.hi) : '—'} , coldest ${y.lo != null ? deg(y.lo) : '—'} , ${y.rain.toFixed(2)}" of rain, strongest gust ${y.gust != null ? Math.round(y.gust) + ' mph' : '—'} . Year-against-year comparisons appear once there is a second year of data.
`;
+ }
+ const rows = [...years].reverse().map((y) => `${y.year} ${y.days} days ${y.avg != null ? y.avg.toFixed(1) + '°F' : '—'} ${y.hi != null ? deg(y.hi) : '—'} ${y.lo != null ? deg(y.lo) : '—'} ${y.rain.toFixed(2)}" ${y.gust != null ? Math.round(y.gust) + ' mph' : '—'} `).join('');
+ return `Year by year `;
+ }
+
+ // Only meaningful once a calendar month has been seen in more than one year
+ function sameMonthTable(r) {
+ const cm = (r.calendarMonths || []).map((c, i) => ({ c, i })).filter((x) => x.c.years > 1);
+ if (!cm.length) return '';
+ const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
+ const cell = (x, f) => x ? `${f(x.value)} ${fmtDay(x.day)} ` : '—';
+ const rows = cm.map(({ c, i }) => `${mn(i)} ${c.years} years of data ${cell(c.hi, deg)} ${cell(c.lo, deg)} ${cell(c.wettestDay, inch)} ${cell(c.gust, mph)} `).join('');
+ return `Records for each month of the year
+ The most extreme day for each calendar month, from any year. For example, the hottest July ever recorded.
+ `;
+ }
+
+ async function load() {
+ try {
+ const [rr, dr] = await Promise.all([fetch('/api/station/records'), fetch('/api/station/daily').catch(() => null)]);
+ if (!rr.ok) { root.innerHTML = 'No records yet.
'; return; }
+ const r = await rr.json();
+ if (!r) { root.innerHTML = 'No data recorded yet.
'; return; }
+ let daily = [];
+ try { if (dr && dr.ok) daily = await dr.json(); } catch {}
+
+ const themes = [...new Set(RECORDS.map((d) => d.theme))];
+ const empty = [];
+ const groups = themes.map((t) => {
+ const cards = RECORDS.filter((d) => d.theme === t).map((d) => {
+ const html = card(d, r, daily);
+ if (!html) empty.push(d.title);
+ return html;
+ }).filter(Boolean);
+ return cards.length ? `${t} ${cards.join('')}
` : '';
+ }).join('');
+
+ root.innerHTML = intro(r) + groups
+ + (empty.length ? `Not measured yet: ${empty.join(', ')}. These show up once the station records something for them.
` : '')
+ + monthTable(r) + yearBlock(r) + sameMonthTable(r)
+ + ``;
+ root.querySelectorAll('.rec-more').forEach((b) => b.addEventListener('click', () => {
+ const open = b.dataset.open !== '1';
+ b.dataset.open = open ? '1' : '';
+ root.querySelectorAll('.rec-extra').forEach((tr) => { tr.hidden = !open; });
+ b.textContent = open ? 'Show fewer months' : b.dataset.label;
+ }));
+ root.querySelectorAll('.rec-more').forEach((b) => { b.dataset.label = b.textContent; });
+ } catch {
+ root.innerHTML = 'Failed to load records.
';
+ }
+ }
+ load();
+})();
diff --git a/app/public/dashboard-ui.css b/app/public/dashboard-ui.css
index b6023f9..76f3b0e 100644
--- a/app/public/dashboard-ui.css
+++ b/app/public/dashboard-ui.css
@@ -155,3 +155,54 @@ a { color: var(--ui); }
/* Performance on phones: blur only the two big fixed bars, lightly */
.logout, .range-btn, .refresh-btn, .chart-card, .stat, .today-bar { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
.header, .tabbar { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
+
+/* ── Records tab (plain-language) ── */
+.rec2 { max-width: 1100px; margin: 0 auto; }
+.rec-intro { background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.2rem; margin-top: 1rem; }
+.rec-intro h2 { font-size: 1.15rem; margin-bottom: 0.45rem; }
+.rec-intro p { font-size: 0.9rem; line-height: 1.55; color: var(--ink-mid); }
+.rec-note { font-size: 0.8rem !important; color: var(--ink-light) !important; margin-top: 0.7rem; line-height: 1.5; }
+.rec2 > .rec-note { margin: 0.9rem 0.2rem 0; }
+.rc-months { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; margin-top: 0.85rem; }
+.rc-months span { text-align: center; font-size: 0.68rem; font-weight: 600; padding: 0.3rem 0; border-radius: 6px; background: var(--paper-darker); color: var(--ink-light); opacity: 0.55; }
+.rc-months span.on { background: var(--accent); color: #fff; opacity: 1; }
+.rec-h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-light); margin: 1.6rem 0.2rem 0.6rem; }
+.rec-sub2 { font-size: 0.85rem; line-height: 1.55; color: var(--ink-mid); margin: -0.2rem 0.2rem 0.7rem; }
+.rc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
+.rc { background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.95rem 1.05rem; display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
+.rc-new { border-color: #d4a017; box-shadow: 0 0 0 1px #d4a01766; }
+.rc-top { display: flex; align-items: center; gap: 0.5rem; }
+.rc-ico { font-size: 1.15rem; line-height: 1; }
+.rc-title { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-mid); }
+.rc-badge { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.08em; padding: 0.12rem 0.4rem; border-radius: 999px; background: #d4a017; color: #1b1400; }
+.rc-top .rc-badge { margin-left: auto; }
+.rc-val { font-size: 1.9rem; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.2rem 0.6rem; }
+.rc-tag { font-size: 0.74rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; background: var(--paper-darker); color: var(--ink-mid); }
+.rc-when { font-size: 0.82rem; font-weight: 600; color: var(--ink); }
+.rc-when span { font-weight: 400; color: var(--ink-light); }
+.rc-why { font-size: 0.8rem; line-height: 1.5; color: var(--ink-light); }
+.rc-today, .rc-next { font-size: 0.78rem; color: var(--ink-mid); padding-top: 0.45rem; margin-top: 0.15rem; border-top: 1px dashed var(--border); }
+.rc-next { border-top: none; padding-top: 0; margin-top: 0; color: var(--ink-light); }
+.rc-next span { opacity: 0.8; }
+.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); }
+.rec-table2 { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
+.rec-table2 th, .rec-table2 td { text-align: left; padding: 0.7rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
+.rec-table2 tr:last-child td { border-bottom: none; }
+.rec-table2 th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); font-weight: 700; white-space: nowrap; }
+.rec-table2 td { font-variant-numeric: tabular-nums; }
+.rec-table2 td:first-child { min-width: 9.5rem; }
+.rec-table2 .rec-yr { display: block; font-size: 0.72rem; color: var(--ink-light); font-weight: 400; margin-top: 0.15rem; }
+.rec-chip { display: inline-block; margin: 0.25rem 0.3rem 0 0; font-size: 0.68rem; font-weight: 600; padding: 0.12rem 0.5rem; border-radius: 999px; background: var(--paper-darker); color: var(--ink-mid); white-space: nowrap; }
+.rec-rng, .rec-rain { min-width: 9rem; }
+.rec-rng > span, .rec-rain > span { display: block; margin-bottom: 0.35rem; }
+.rec-track { display: block; position: relative; height: 7px; border-radius: 99px; background: var(--paper-darker); }
+.rec-track > i { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: linear-gradient(90deg, var(--cool), var(--accent)); }
+.rec-track.r > i { left: 0; background: var(--cool); }
+@media (max-width: 640px) {
+ .rc-grid { grid-template-columns: 1fr; }
+ .rc-val { font-size: 1.7rem; }
+ .rec-table2 th, .rec-table2 td { padding: 0.6rem 0.65rem; }
+}
+
+.rec-extra[hidden] { display: none; }
+.rec-more { display: block; margin: 0.7rem auto 0; min-height: 40px; padding: 0 1.1rem; border: 1px solid var(--border); border-radius: 999px; background: var(--paper-dark); color: var(--ink); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
diff --git a/app/public/dashboard.html b/app/public/dashboard.html
index 558626b..54327be 100644
--- a/app/public/dashboard.html
+++ b/app/public/dashboard.html
@@ -574,21 +574,8 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
-
-
- All-Time Station Records
-
-
-
-
-
- Monthly Summary
-
-
+
+
@@ -1814,5 +1801,6 @@ setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000);
+