/* 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 `
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.
How each month compared. The temperature bar shows the coldest to hottest reading that month, on one shared scale.
| Month | Average temp | Coldest to hottest | Total rain | Strongest gust |
|---|
${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) => `| Year | Average temp | Hottest | Coldest | Total rain | Strongest gust |
|---|
The most extreme day for each calendar month, from any year. For example, the hottest July ever recorded.
| Month | Hottest day | Coldest | Wettest day | Strongest gust |
|---|
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 = '