/* 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.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 ``; } 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.

${rows}
MonthAverage tempColdest to hottestTotal rainStrongest gust
${months.length > LIMIT ? `` : ''}`; } 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

${rows}
YearAverage tempHottestColdestTotal rainStrongest gust
`; } // 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.

${rows}
MonthHottest dayColdestWettest dayStrongest gust
`; } 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) + `
โฌ‡ Download all readingsโฌ‡ Download records
`; 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(); })();