Persistent station records, backups, and iPhone-friendly dashboard

- Keep per-day extremes in data/records.json (merge-only) and derive all-time,
  notable-day, monthly and yearly records from it; rebuild from raw files at startup
- Use station maxdailygust for true gust extremes
- Add /api/station/backup.jsonl, records-backup.json and records/rebuild
- Dashboard: safe-area insets, 44px touch targets, scrollable nav/range bars,
  chart touch-action, standalone PWA manifest + apple meta tags

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:14:25 -04:00
1 parent 4c2c619fb6
commit a97d104d14
4 files changed
+315 -60

No files matched your search

+8 -2
View File
@@ -2,7 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#191510">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Station">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<title>Station Dashboard — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -13,7 +19,7 @@
@media (prefers-color-scheme: dark) {
:root { --ink: #ece7dc; --paper: #191510; --paper-dark: #201c16; --border: #3a342a; --accent: #e07848; }
}
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; }
html, body { min-height: 100vh; min-height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; }
.card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(380px, 94vw); }
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
.sub { color: var(--border); font-size: 0.85rem; margin-bottom: 2rem; }
+86 -2
View File
@@ -2,7 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#191510" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Station">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<title>Station Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
@@ -321,6 +329,47 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
box-shadow: 0 0 0 2px rgba(255,195,0,0.12);
}
.record-best .record-label::after { content: ' ★'; color: #ffc300; }
/* ── iPhone / touch ── */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
body { overscroll-behavior-y: none; min-height: 100vh; min-height: 100dvh; }
.header { padding-top: calc(1rem + env(safe-area-inset-top)); padding-left: calc(1.5rem + env(safe-area-inset-left)); padding-right: calc(1.5rem + env(safe-area-inset-right)); }
.page { padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); padding-bottom: calc(4rem + env(safe-area-inset-bottom)); }
/* Let the page scroll when a finger starts on a chart; taps/long-press still show tooltips */
.chart-wrap canvas { touch-action: pan-y; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
button, .range-btn, .refresh-btn, .logout, summary { touch-action: manipulation; }
input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
@media (hover: none) {
.chart-card:hover, .today-bar:hover { transform: none; box-shadow: none; }
}
@media (pointer: coarse) {
.range-btn, .refresh-btn, .logout { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding-left: 0.9rem; padding-right: 0.9rem; }
.settings-panel summary { min-height: 48px; }
}
@media (max-width: 640px) {
.header { flex-wrap: wrap; gap: 0.6rem; padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); }
.header-right { width: 100%; flex-wrap: nowrap; overflow-x: auto; gap: 0.5rem; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 2px; }
.header-right::-webkit-scrollbar { display: none; }
.header-right > * { flex-shrink: 0; }
.range-bar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; }
.range-bar::-webkit-scrollbar { display: none; }
.range-bar > * { flex-shrink: 0; }
.today-bar { flex-wrap: wrap; row-gap: 0.4rem; }
.chart-card { padding: 0.8rem 0.8rem 1rem; }
.chart-wrap { height: 220px; }
.settings-body { padding: 0.9rem; }
.records-grid { grid-template-columns: repeat(2, 1fr); }
.record-val { font-size: 1.05rem; }
}
/* Records tables */
.rec-h { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin: 1.1rem 0 0.5rem; }
.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.rec-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
.rec-table th, .rec-table td { text-align: right; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.rec-table th:first-child, .rec-table td:first-child { text-align: left; }
.rec-table th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); font-weight: 600; }
.rec-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.1rem; }
.rec-actions a { display: inline-flex; align-items: center; min-height: 40px; padding: 0 0.9rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.8rem; font-weight: 600; background: var(--paper); }
</style>
</head>
<body>
@@ -1036,6 +1085,36 @@ function fmtDate(ts) {
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderRecordPeriods(d, ri) {
const dayDate = (day) => day ? Date.parse(day + 'T12:00:00Z') : null;
const n = d.notable || {};
const nv = (x, f) => x ? f(x.value) : '—';
let html = '<div class="rec-h">Notable days</div><div class="records-grid">' + [
['Hottest Day', nv(n.hottestDay, v => v.toFixed(1) + '°F'), n.hottestDay],
['Coldest Night', nv(n.coldestDay, v => v.toFixed(1) + '°F'), n.coldestDay],
['Coolest Daytime High', nv(n.coolestHigh, v => v.toFixed(1) + '°F'), n.coolestHigh],
['Warmest Night', nv(n.warmestLow, v => v.toFixed(1) + '°F'), n.warmestLow],
['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('') + '</div>';
if (d.wettestMonth || d.wettestYear) {
html += '<div class="rec-h">Wettest periods</div><div class="records-grid">'
+ (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')) : '')
+ '</div>';
}
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
const row = (label, a) => `<tr><td>${label}</td><td>${a.hi != null ? a.hi.toFixed(1) : '—'}</td><td>${a.lo != null ? a.lo.toFixed(1) : '—'}</td><td>${a.gust != null ? a.gust.toFixed(0) : '—'}</td><td>${a.rain.toFixed(2)}</td><td>${a.days}</td></tr>`;
const head = '<tr><th></th><th>High °F</th><th>Low °F</th><th>Gust</th><th>Rain "</th><th>Days</th></tr>';
if (d.years && d.years.length) {
html += `<div class="rec-h">By year</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.years].reverse().map(y => row(y.year, y)).join('')}</table></div>`;
}
if (d.months && d.months.length) {
html += `<div class="rec-h">By month</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.months].reverse().map(m => row(mname(m.month), m)).join('')}</table></div>`;
}
return html;
}
async function loadRecords() {
const panel = document.getElementById('recordsPanel');
if (!panel) return;
@@ -1048,7 +1127,7 @@ async function loadRecords() {
if (!r.ok) { body.innerHTML = '<div class="status">No records yet.</div>'; return; }
const d = await r.json();
if (!d) { body.innerHTML = '<div class="status">No data recorded yet.</div>'; return; }
const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings since ${fmtDate(d.firstReading)}` : '';
const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings over ${d.daysRecorded} days since ${fmtDate(d.firstReading)}` : '';
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
const ri = (label, val, date) => {
const recent = date && new Date(date).getTime() > cutoff;
@@ -1068,6 +1147,11 @@ async function loadRecords() {
${ri('Lowest Pressure', d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—', d.lowPressureDate)}
${ri('Highest Humidity', d.highHumidity != null ? d.highHumidity + '%' : '—', d.highHumidityDate)}
${ri('Lowest Humidity', d.lowHumidity != null ? d.lowHumidity + '%' : '—', d.lowHumidityDate)}
</div>
${renderRecordPeriods(d, ri)}
<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>`;
} catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; }
}, { once: false });
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Station Dashboard",
"short_name": "Station",
"description": "Personal weather station dashboard",
"start_url": "/dashboard",
"scope": "/dashboard",
"display": "standalone",
"background_color": "#191510",
"theme_color": "#191510",
"orientation": "any",
"categories": ["weather", "utilities"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}