Publish Container / publish (push) Successful in 3m8s
Replaces the collapsed panel of look-alike cards with a guided view: an intro explaining what a record is (and that records are 'so far' with a 12-month coverage strip), cards grouped by theme with a plain explanation, date/time, how long ago, descriptors (gust/UV/rain intensity), today-vs-record and next-closest days, NEW badges for records set in the last 14 days, one month-by-month table with temperature-range and rain bars (latest 12, show all), and year / same-month tables only when there is more than a year of data. Removes duplicate cards and the redundant Monthly Summary panel. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
218 lines
16 KiB
JavaScript
218 lines
16 KiB
JavaScript
/* 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 `<div class="rc-today">${label}: <b>${def.fmt(v)}</b> · ${where}</div>`;
|
|
}
|
|
|
|
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
|
|
? `<div class="rc-next">Next closest: ${ru.map((x) => `${def.fmt(x[def.field])} <span>(${new Date(dayMs(x.d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })})</span>`).join(' · ')}</div>`
|
|
: '';
|
|
return `<div class="rc${isNew ? ' rc-new' : ''}">
|
|
<div class="rc-top"><span class="rc-ico" aria-hidden="true">${def.icon}</span><span class="rc-title">${def.title}</span>${isNew ? '<span class="rc-badge">NEW</span>' : ''}</div>
|
|
<div class="rc-val">${def.fmt(rec.value)}${def.tag ? `<span class="rc-tag">${def.tag(rec.value)}</span>` : ''}</div>
|
|
<div class="rc-when">${when}<span> · ${agoText(rec.day)}</span></div>
|
|
<div class="rc-why">${def.why}</div>
|
|
${todayLine(def, rec, daily)}${ruLine}
|
|
</div>`;
|
|
}
|
|
|
|
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 `<div class="rc-months" role="img" aria-label="Months with recorded data">${names.map((n, i) => `<span class="${have.has(i + 1) ? 'on' : ''}">${n}</span>`).join('')}</div>`;
|
|
}
|
|
|
|
function intro(r) {
|
|
const missing = 12 - new Set((r.months || []).map((m) => +m.month.slice(5, 7))).size;
|
|
return `<div class="rec-intro">
|
|
<h2>Your station's records</h2>
|
|
<p><b>A record is the most extreme weather your station has measured.</b> Your station has been recording for <b>${r.daysRecorded} days</b> (since ${fmtDay(r.firstDay)}), so these are records <i>so far</i>. They will change as the seasons go by.</p>
|
|
${monthStrip(r)}
|
|
<p class="rec-note">${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 <span class="rc-badge">NEW</span> badge means the record was set in the last 14 days.</p>
|
|
</div>`;
|
|
}
|
|
|
|
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 `<tr${idx >= LIMIT ? ' class="rec-extra" hidden' : ''}>
|
|
<td><b>${longMonth(m.month)}</b>${(chips[m.month] || []).map((c) => `<span class="rec-chip">${c}</span>`).join('')}${partial ? `<span class="rec-yr">${m.days} of ${total} days so far</span>` : ''}</td>
|
|
<td>${m.avg != null ? m.avg.toFixed(1) + '°F' : '—'}</td>
|
|
<td class="rec-rng"><span>${m.lo != null ? Math.round(m.lo) : '—'}° to ${m.hi != null ? Math.round(m.hi) : '—'}°</span><i class="rec-track"><i style="left:${left.toFixed(1)}%;width:${width.toFixed(1)}%"></i></i></td>
|
|
<td class="rec-rain"><span>${m.rain.toFixed(2)}"</span><i class="rec-track r"><i style="width:${((m.rain / maxRain) * 100).toFixed(1)}%"></i></i></td>
|
|
<td>${m.gust != null ? Math.round(m.gust) + ' mph' : '—'}</td></tr>`;
|
|
}).join('');
|
|
return `<h3 class="rec-h2">Month by month</h3>
|
|
<p class="rec-sub2">How each month compared. The temperature bar shows the coldest to hottest reading that month, on one shared scale.</p>
|
|
<div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Month</th><th>Average temp</th><th>Coldest to hottest</th><th>Total rain</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>${months.length > LIMIT ? `<button type="button" class="rec-more" data-more="month">Show all ${months.length} months</button>` : ''}`;
|
|
}
|
|
|
|
function yearBlock(r) {
|
|
const years = r.years || [];
|
|
if (!years.length) return '';
|
|
if (years.length === 1) {
|
|
const y = years[0];
|
|
return `<h3 class="rec-h2">${y.year} so far</h3>
|
|
<p class="rec-sub2">${y.days} days recorded: hottest <b>${y.hi != null ? deg(y.hi) : '—'}</b>, coldest <b>${y.lo != null ? deg(y.lo) : '—'}</b>, <b>${y.rain.toFixed(2)}"</b> of rain, strongest gust <b>${y.gust != null ? Math.round(y.gust) + ' mph' : '—'}</b>. Year-against-year comparisons appear once there is a second year of data.</p>`;
|
|
}
|
|
const rows = [...years].reverse().map((y) => `<tr><td><b>${y.year}</b><span class="rec-yr">${y.days} days</span></td><td>${y.avg != null ? y.avg.toFixed(1) + '°F' : '—'}</td><td>${y.hi != null ? deg(y.hi) : '—'}</td><td>${y.lo != null ? deg(y.lo) : '—'}</td><td>${y.rain.toFixed(2)}"</td><td>${y.gust != null ? Math.round(y.gust) + ' mph' : '—'}</td></tr>`).join('');
|
|
return `<h3 class="rec-h2">Year by year</h3><div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Year</th><th>Average temp</th><th>Hottest</th><th>Coldest</th><th>Total rain</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
}
|
|
|
|
// 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 ? `<b>${f(x.value)}</b><span class="rec-yr">${fmtDay(x.day)}</span>` : '—';
|
|
const rows = cm.map(({ c, i }) => `<tr><td><b>${mn(i)}</b><span class="rec-yr">${c.years} years of data</span></td><td>${cell(c.hi, deg)}</td><td>${cell(c.lo, deg)}</td><td>${cell(c.wettestDay, inch)}</td><td>${cell(c.gust, mph)}</td></tr>`).join('');
|
|
return `<h3 class="rec-h2">Records for each month of the year</h3>
|
|
<p class="rec-sub2">The most extreme day for each calendar month, from any year. For example, the hottest July ever recorded.</p>
|
|
<div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Month</th><th>Hottest day</th><th>Coldest</th><th>Wettest day</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
}
|
|
|
|
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 = '<div class="status">No records yet.</div>'; return; }
|
|
const r = await rr.json();
|
|
if (!r) { root.innerHTML = '<div class="status">No data recorded yet.</div>'; 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 ? `<h3 class="rec-h2">${t}</h3><div class="rc-grid">${cards.join('')}</div>` : '';
|
|
}).join('');
|
|
|
|
root.innerHTML = intro(r) + groups
|
|
+ (empty.length ? `<p class="rec-note">Not measured yet: ${empty.join(', ')}. These show up once the station records something for them.</p>` : '')
|
|
+ monthTable(r) + yearBlock(r) + sameMonthTable(r)
|
|
+ `<div class="rec-actions"><a href="/api/station/backup.jsonl" download>⬇ Download all readings</a><a href="/api/station/records-backup.json" download>⬇ Download records</a></div>`;
|
|
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 = '<div class="status error">Failed to load records.</div>';
|
|
}
|
|
}
|
|
load();
|
|
})();
|