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:
1 parent
4c2c619fb6
commit
a97d104d14
4 files changed
+315
-60
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user