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" }
|
||||
]
|
||||
}
|
||||
+205
-56
@@ -148,6 +148,9 @@ function saveStationReading(obs) {
|
||||
if (seen.has(ts)) return false;
|
||||
fs.appendFileSync(stationDayFile(day), JSON.stringify(obs) + '\n');
|
||||
seen.add(ts);
|
||||
if (typeof updateRecordsFromReading === 'function') {
|
||||
try { updateRecordsFromReading(obs); } catch (e) { console.error('[records] update error:', e.message); }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -191,6 +194,173 @@ function dedupeStationFiles() {
|
||||
}
|
||||
dedupeStationFiles();
|
||||
|
||||
// ── Persistent all-time records ───────────────────────────────────────────────
|
||||
// data/records.json keeps one small summary per local day (extremes + the time they
|
||||
// happened). All-time / monthly / yearly records are derived from it, so they survive
|
||||
// even if raw readings are pruned or lost. Merging only ever widens an extreme.
|
||||
const RECORDS_FILE = path.join(DATA_DIR, 'records.json');
|
||||
let recordsStore = { version: 1, days: {} };
|
||||
try {
|
||||
const loaded = JSON.parse(fs.readFileSync(RECORDS_FILE, 'utf8'));
|
||||
if (loaded && loaded.days) recordsStore = loaded;
|
||||
} catch {}
|
||||
let recordsDirty = false;
|
||||
|
||||
// [field, source key(s), 'max' | 'min']
|
||||
const DAY_FIELDS = [
|
||||
['hi', ['tempf'], 'max'], ['lo', ['tempf'], 'min'],
|
||||
['gust', ['windgustmph', 'maxdailygust'], 'max'],
|
||||
['rate', ['rainratein'], 'max'],
|
||||
['uv', ['uv'], 'max'], ['solar', ['solarradiation'], 'max'],
|
||||
['pmax', ['baromrelin'], 'max'], ['pmin', ['baromrelin'], 'min'],
|
||||
['hmax', ['humidity'], 'max'], ['hmin', ['humidity'], 'min'],
|
||||
];
|
||||
|
||||
function mergeExtreme(day, field, kind, val, ts) {
|
||||
if (!Number.isFinite(val)) return false;
|
||||
const cur = day[field];
|
||||
if (!cur || (kind === 'max' ? val > cur[0] : val < cur[0])) { day[field] = [val, ts]; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyReadingToRecords(obs) {
|
||||
const dayKey = stationLocalDay(obs);
|
||||
const day = recordsStore.days[dayKey] || (recordsStore.days[dayKey] = { n: 0 });
|
||||
let changed = false;
|
||||
for (const [field, keys, kind] of DAY_FIELDS) {
|
||||
for (const k of keys) {
|
||||
if (obs[k] != null && mergeExtreme(day, field, kind, Number(obs[k]), obs.dateutc)) changed = true;
|
||||
}
|
||||
}
|
||||
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && !(Number(obs.dailyrainin) <= (day.rain || 0))) {
|
||||
day.rain = Number(obs.dailyrainin); changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function saveRecordsStore() {
|
||||
if (!recordsDirty) return;
|
||||
try {
|
||||
fs.writeFileSync(RECORDS_FILE + '.tmp', JSON.stringify(recordsStore));
|
||||
fs.renameSync(RECORDS_FILE + '.tmp', RECORDS_FILE);
|
||||
recordsDirty = false;
|
||||
} catch (e) { console.error('[records] save error:', e.message); }
|
||||
}
|
||||
|
||||
function updateRecordsFromReading(obs) {
|
||||
const before = (recordsStore.days[stationLocalDay(obs)] || {}).n || 0;
|
||||
applyReadingToRecords(obs);
|
||||
recordsStore.days[stationLocalDay(obs)].n = before + 1;
|
||||
recordsDirty = true;
|
||||
}
|
||||
|
||||
// Re-scan raw readings and merge into the store (never lowers an existing record).
|
||||
function rebuildRecordsFromFiles() {
|
||||
let files;
|
||||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
|
||||
const counts = {};
|
||||
let scanned = 0;
|
||||
for (const f of files) {
|
||||
try {
|
||||
const seen = new Set();
|
||||
for (const l of fs.readFileSync(path.join(STATION_DIR, f), 'utf8').split('\n')) {
|
||||
if (!l) continue;
|
||||
let r; try { r = JSON.parse(l); } catch { continue; }
|
||||
if (!Number.isFinite(r.dateutc) || seen.has(r.dateutc)) continue;
|
||||
seen.add(r.dateutc);
|
||||
applyReadingToRecords(r);
|
||||
const k = stationLocalDay(r);
|
||||
counts[k] = (counts[k] || 0) + 1;
|
||||
scanned++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Reading counts come from the files; keep the larger so pruned history isn't forgotten.
|
||||
for (const [k, n] of Object.entries(counts)) recordsStore.days[k].n = Math.max(recordsStore.days[k].n || 0, n);
|
||||
recordsDirty = true;
|
||||
saveRecordsStore();
|
||||
return scanned;
|
||||
}
|
||||
|
||||
function deriveRecords() {
|
||||
const days = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (!days.length) return null;
|
||||
const top = (field, kind) => {
|
||||
let best = null, bestDay = null;
|
||||
for (const [d, v] of days) {
|
||||
const e = v[field]; if (!e) continue;
|
||||
if (best == null || (kind === 'max' ? e[0] > best[0] : e[0] < best[0])) { best = e; bestDay = d; }
|
||||
}
|
||||
return best ? { value: best[0], ts: best[1], day: bestDay } : null;
|
||||
};
|
||||
const rec = {
|
||||
highTemp: top('hi', 'max'), lowTemp: top('lo', 'min'), highGust: top('gust', 'max'),
|
||||
highRainRate: top('rate', 'max'), highUV: top('uv', 'max'), highSolar: top('solar', 'max'),
|
||||
highPressure: top('pmax', 'max'), lowPressure: top('pmin', 'min'),
|
||||
highHumidity: top('hmax', 'max'), lowHumidity: top('hmin', 'min'),
|
||||
};
|
||||
let wet = null;
|
||||
for (const [d, v] of days) if (v.rain != null && (!wet || v.rain > wet.value)) wet = { value: v.rain, day: d };
|
||||
rec.highDailyRain = wet;
|
||||
|
||||
const out = {};
|
||||
const flat = {
|
||||
highTemp: 'highTemp', lowTemp: 'lowTemp', highGust: 'highGust', highRainRate: 'highRainRate',
|
||||
highDailyRain: 'highDailyRain', highUV: 'highUV', highSolar: 'highSolar',
|
||||
highPressure: 'highPressure', lowPressure: 'lowPressure', highHumidity: 'highHumidity', lowHumidity: 'lowHumidity',
|
||||
};
|
||||
for (const [k, src] of Object.entries(flat)) {
|
||||
out[k] = rec[src] ? rec[src].value : null;
|
||||
out[k + 'Date'] = rec[src] ? (rec[src].ts || Date.parse(rec[src].day + 'T12:00:00Z')) : null;
|
||||
}
|
||||
out.totalReadings = days.reduce((a, [, v]) => a + (v.n || 0), 0);
|
||||
out.firstDay = days[0][0];
|
||||
out.lastDay = days[days.length - 1][0];
|
||||
out.firstReading = Date.parse(out.firstDay + 'T12:00:00Z');
|
||||
out.lastReading = Date.parse(out.lastDay + 'T12:00:00Z');
|
||||
out.daysRecorded = days.length;
|
||||
|
||||
// Notable days
|
||||
const dayRec = (field, kind) => { const t = top(field, kind); return t ? { value: t.value, day: t.day } : null; };
|
||||
out.notable = {
|
||||
hottestDay: dayRec('hi', 'max'),
|
||||
coldestDay: dayRec('lo', 'min'),
|
||||
coolestHigh: dayRec('hi', 'min'),
|
||||
warmestLow: dayRec('lo', 'max'),
|
||||
windiestDay: dayRec('gust', 'max'),
|
||||
wettestDay: rec.highDailyRain,
|
||||
};
|
||||
|
||||
// Per-period aggregation (month-of-year and calendar year)
|
||||
const agg = (keyFn) => {
|
||||
const g = {};
|
||||
for (const [d, v] of days) {
|
||||
const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null });
|
||||
a.days++; a.rain += v.rain || 0;
|
||||
if (v.hi && (a.hi == null || v.hi[0] > a.hi)) a.hi = v.hi[0];
|
||||
if (v.lo && (a.lo == null || v.lo[0] < a.lo)) a.lo = v.lo[0];
|
||||
if (v.gust && (a.gust == null || v.gust[0] > a.gust)) a.gust = v.gust[0];
|
||||
}
|
||||
for (const a of Object.values(g)) a.rain = +a.rain.toFixed(2);
|
||||
return g;
|
||||
};
|
||||
const byYear = agg(d => d.slice(0, 4));
|
||||
out.years = Object.entries(byYear).sort(([a], [b]) => a.localeCompare(b)).map(([year, a]) => ({ year, ...a }));
|
||||
const byMonth = agg(d => d.slice(0, 7));
|
||||
const ym = Object.entries(byMonth).sort(([a], [b]) => a.localeCompare(b)).map(([month, a]) => ({ month, ...a }));
|
||||
out.months = ym;
|
||||
out.wettestMonth = ym.reduce((b, m) => (!b || m.rain > b.rain ? m : b), null);
|
||||
out.wettestYear = out.years.reduce((b, y) => (!b || y.rain > b.rain ? y : b), null);
|
||||
return out;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!Object.keys(recordsStore.days).length) console.log(`[records] built from ${rebuildRecordsFromFiles()} readings`);
|
||||
else rebuildRecordsFromFiles();
|
||||
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
||||
setInterval(saveRecordsStore, 30 * 1000).unref();
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); process.exit(0); });
|
||||
|
||||
function loadStationReadings(rangeMs) {
|
||||
const now = Date.now();
|
||||
const cutoff = now - rangeMs;
|
||||
@@ -1244,65 +1414,44 @@ app.get('/api/station/backfill/status', requireDashboardAuth, (_req, res) => {
|
||||
|
||||
// ── Station analytics ─────────────────────────────────────────────────────────
|
||||
|
||||
// Records — scan all JSONL, cache 1h
|
||||
let recordsCache = null;
|
||||
let recordsCacheAt = 0;
|
||||
|
||||
function computeStationRecords() {
|
||||
let files;
|
||||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return null; }
|
||||
if (!files.length) return null;
|
||||
const rec = {
|
||||
highTemp: null, highTempDate: null, lowTemp: null, lowTempDate: null,
|
||||
highGust: null, highGustDate: null,
|
||||
highRainRate: null, highRainRateDate: null, highDailyRain: null, highDailyRainDate: null,
|
||||
highUV: null, highUVDate: null, highSolar: null, highSolarDate: null,
|
||||
highPressure: null, highPressureDate: null, lowPressure: null, lowPressureDate: null,
|
||||
highHumidity: null, highHumidityDate: null, lowHumidity: null, lowHumidityDate: null,
|
||||
totalReadings: 0, firstReading: null, lastReading: null,
|
||||
};
|
||||
for (const file of files) {
|
||||
try {
|
||||
const lines = fs.readFileSync(path.join(STATION_DIR, file), 'utf8').split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const r = JSON.parse(line);
|
||||
if (!r.dateutc) continue;
|
||||
rec.totalReadings++;
|
||||
if (!rec.firstReading || r.dateutc < rec.firstReading) rec.firstReading = r.dateutc;
|
||||
if (!rec.lastReading || r.dateutc > rec.lastReading) rec.lastReading = r.dateutc;
|
||||
if (r.tempf != null) {
|
||||
if (rec.highTemp == null || r.tempf > rec.highTemp) { rec.highTemp = r.tempf; rec.highTempDate = r.dateutc; }
|
||||
if (rec.lowTemp == null || r.tempf < rec.lowTemp) { rec.lowTemp = r.tempf; rec.lowTempDate = r.dateutc; }
|
||||
}
|
||||
if (r.windgustmph != null && (rec.highGust == null || r.windgustmph > rec.highGust)) { rec.highGust = r.windgustmph; rec.highGustDate = r.dateutc; }
|
||||
if (r.rainratein != null && (rec.highRainRate == null || r.rainratein > rec.highRainRate)) { rec.highRainRate = r.rainratein; rec.highRainRateDate = r.dateutc; }
|
||||
if (r.dailyrainin != null && (rec.highDailyRain == null || r.dailyrainin > rec.highDailyRain)) { rec.highDailyRain = r.dailyrainin; rec.highDailyRainDate = r.dateutc; }
|
||||
if (r.uv != null && (rec.highUV == null || r.uv > rec.highUV)) { rec.highUV = r.uv; rec.highUVDate = r.dateutc; }
|
||||
if (r.solarradiation != null && (rec.highSolar == null || r.solarradiation > rec.highSolar)) { rec.highSolar = r.solarradiation; rec.highSolarDate = r.dateutc; }
|
||||
if (r.baromrelin != null) {
|
||||
if (rec.highPressure == null || r.baromrelin > rec.highPressure) { rec.highPressure = r.baromrelin; rec.highPressureDate = r.dateutc; }
|
||||
if (rec.lowPressure == null || r.baromrelin < rec.lowPressure) { rec.lowPressure = r.baromrelin; rec.lowPressureDate = r.dateutc; }
|
||||
}
|
||||
if (r.humidity != null) {
|
||||
if (rec.highHumidity == null || r.humidity > rec.highHumidity) { rec.highHumidity = r.humidity; rec.highHumidityDate = r.dateutc; }
|
||||
if (rec.lowHumidity == null || r.humidity < rec.lowHumidity) { rec.lowHumidity = r.humidity; rec.lowHumidityDate = r.dateutc; }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Records — derived from the persistent per-day store (data/records.json)
|
||||
app.get('/api/station/records', requireDashboardAuth, (_req, res) => {
|
||||
if (recordsCache && Date.now() - recordsCacheAt < 3600000) return res.json(recordsCache);
|
||||
recordsCache = computeStationRecords();
|
||||
recordsCacheAt = Date.now();
|
||||
res.json(recordsCache);
|
||||
res.json(deriveRecords());
|
||||
});
|
||||
|
||||
// Re-scan raw readings into the records store (merge only; never lowers a record)
|
||||
app.post('/api/station/records/rebuild', requireDashboardAuth, (_req, res) => {
|
||||
const scanned = rebuildRecordsFromFiles();
|
||||
res.json({ ok: true, scanned });
|
||||
});
|
||||
|
||||
// Full backup: every saved reading (deduped, oldest first) as newline-delimited JSON
|
||||
app.get('/api/station/backup.jsonl', requireDashboardAuth, (_req, res) => {
|
||||
let files;
|
||||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { files = []; }
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="station-backup-${new Date().toISOString().slice(0, 10)}.jsonl"`);
|
||||
for (const f of files) {
|
||||
try {
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const l of fs.readFileSync(path.join(STATION_DIR, f), 'utf8').split('\n')) {
|
||||
if (!l) continue;
|
||||
try { const t = JSON.parse(l).dateutc; if (seen.has(t)) continue; seen.add(t); } catch {}
|
||||
rows.push(l);
|
||||
}
|
||||
if (rows.length) res.write(rows.join('\n') + '\n');
|
||||
} catch {}
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Records store (per-day extremes) as JSON
|
||||
app.get('/api/station/records-backup.json', requireDashboardAuth, (_req, res) => {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="station-records-${new Date().toISOString().slice(0, 10)}.json"`);
|
||||
res.json(recordsStore);
|
||||
});
|
||||
|
||||
// Rain totals — max dailyrainin per calendar day, summed
|
||||
app.get('/api/station/rain-totals', requireDashboardAuth, (_req, res) => {
|
||||
const now = Date.now();
|
||||
const ytdCutoff = new Date(new Date().getFullYear(), 0, 1).getTime();
|
||||
|
||||
Reference in new issue
Block a user