Files
weather/app/public/dashboard.html
T
Nate EmmertandClaude Sonnet 4.6 2986301599 Fix wind direction rose invisible on iOS (Retina DPR bug)
drawWindRose used canvas.width (physical pixels) for coordinates while
the context was already pre-scaled by devicePixelRatio. On iPhones
(dpr=2/3) this placed the entire rose off-screen. Fix by dividing by
dpr to get CSS dimensions before computing cx/cy/R.

Also make sWindDir stat always show a value ('—' fallback when winddir
is null) and add null guards on sEventRainTile/sTotalRainTile to
prevent a silent throw from aborting updateLatest() early.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-24 15:38:16 -04:00

1566 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649;
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7;
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399;
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620;
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8;
}
}
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
a { color: var(--cool); text-decoration: none; }
/* ── Header ── */
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); background: var(--paper-dark); position: sticky; top: 0; z-index: 10; }
.header h1 { font-size: 1.15rem; }
.header .meta { font-size: 0.8rem; color: var(--ink-light); }
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
.station-name { color: var(--cool); font-weight: 600; }
.logout { color: var(--accent); }
/* ── Wrapper ── */
.page { max-width: 1200px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
/* ── Stat tiles ── */
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1.75rem; }
.stat { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 12px; padding: 0.85rem 1rem; }
.stat-label { font-size: 0.72rem; color: var(--ink-light); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; }
.stat-val { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
.stat-unit { font-size: 0.75rem; color: var(--ink-light); margin-top: 0.1rem; }
/* ── Range selector ── */
.range-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.range-bar span { font-size: 0.82rem; color: var(--ink-light); margin-right: 0.25rem; }
.range-btn { padding: 0.3rem 0.75rem; border: 1px solid var(--border); border-radius: 20px; background: transparent; color: var(--ink); font-family: var(--mono); font-size: 0.82rem; cursor: pointer; transition: background 0.15s; }
.range-btn.active, .range-btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.refresh-btn { margin-left: auto; padding: 0.3rem 0.75rem; border: 1px solid var(--border); border-radius: 20px; background: transparent; color: var(--ink-light); font-family: var(--mono); font-size: 0.82rem; cursor: pointer; }
.refresh-btn:hover { border-color: var(--cool); color: var(--cool); }
/* ── Chart grid ── */
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 720px) { .charts { grid-template-columns: 1fr; } }
.chart-card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.2rem 1.2rem; }
.chart-card.wide { grid-column: 1 / -1; }
.chart-title { font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin-bottom: 0.75rem; }
.chart-wrap { position: relative; height: 200px; }
.chart-card.wide .chart-wrap { height: 180px; }
/* ── Status ── */
.status { text-align: center; padding: 3rem 1rem; color: var(--ink-light); font-size: 0.9rem; }
.status.error { color: var(--accent); }
.updated { font-size: 0.75rem; color: var(--ink-light); margin-top: 0.5rem; }
/* ── Wind rose ── */
#windRoseCanvas { display: block; margin: 0 auto; }
/* ── No-data ── */
.no-data { display: flex; align-items: center; justify-content: center; height: 180px; font-size: 0.85rem; color: var(--ink-light); }
/* ── Settings panel ── */
.settings-panel { margin-top: 2.5rem; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.settings-panel summary { padding: 0.85rem 1.2rem; cursor: pointer; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); background: var(--paper-dark); list-style: none; display: flex; align-items: center; gap: 0.5rem; user-select: none; }
.settings-panel summary::before { content: '›'; display: inline-block; transition: transform 0.2s; }
.settings-panel[open] summary::before { transform: rotate(90deg); }
.settings-panel summary::-webkit-details-marker { display: none; }
.settings-body { padding: 1.2rem; background: var(--paper-dark); border-top: 1px solid var(--border); }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 0.75rem 0; }
@media (max-width: 600px) { .settings-grid { grid-template-columns: 1fr; } }
.settings-body label { display: block; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.3rem; }
.settings-body input { width: 100%; padding: 0.5rem 0.75rem; background: var(--paper); border: 1px solid var(--border); border-radius: 8px; color: var(--ink); font-size: 0.9rem; font-family: var(--mono); }
.settings-body input:focus { outline: 2px solid var(--accent); }
.settings-actions { display: flex; align-items: center; gap: 1rem; margin-top: 0.75rem; }
.settings-body button { padding: 0.45rem 1.2rem; background: var(--accent); color: #fff; border: none; border-radius: 8px; font-family: var(--mono); font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.settings-body button:hover { opacity: 0.88; }
.save-msg { font-size: 0.85rem; color: var(--cool); }
.save-msg.err { color: var(--accent); }
.keys-status { font-size: 0.85rem; margin-bottom: 0.25rem; }
.keys-status.ok { color: var(--cool); }
.keys-status.missing { color: var(--accent); }
.keys-hint { font-size: 0.78rem; color: var(--ink-light); margin-top: 1rem; line-height: 1.5; }
.settings-divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
/* Records */
.records-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; }
.record-item { background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.85rem; }
.record-label { font-size: 0.72rem; color: var(--ink-light); text-transform: uppercase; letter-spacing: 0.05em; }
.record-val { font-size: 1.15rem; font-weight: 700; margin: 0.1rem 0; }
.record-date { font-size: 0.72rem; color: var(--ink-light); }
/* Anomaly tile */
.stat.anomaly-above { border-color: var(--accent); }
.stat.anomaly-below { border-color: var(--cool); }
/* Export link */
.export-link { display: inline-block; padding: 0.45rem 1.2rem; background: var(--cool); color: #fff !important; border-radius: 8px; font-size: 0.9rem; font-weight: 600; text-decoration: none !important; }
.export-link:hover { opacity: 0.88; }
.backfill-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.backfill-controls { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; }
.backfill-controls select { padding: 0.45rem 0.6rem; background: var(--paper); border: 1px solid var(--border); border-radius: 8px; color: var(--ink); font-family: var(--mono); font-size: 0.85rem; }
.progress-bar-wrap { height: 6px; background: var(--paper-darker); border-radius: 3px; margin-top: 1rem; overflow: hidden; }
.progress-bar { height: 100%; background: var(--cool); border-radius: 3px; transition: width 0.5s; width: 0%; }
.progress-text { font-size: 0.82rem; color: var(--ink-light); margin-top: 0.4rem; }
.backfill-progress { margin-top: 0.75rem; }
/* NWS alert cards */
.alert-card { border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 0.5rem; font-size: 0.85rem; border: 1px solid; }
.alert-card.sev-extreme { background: rgba(120,0,0,0.12); border-color: #a00; color: var(--ink); }
.alert-card.sev-severe { background: rgba(194,90,42,0.12); border-color: var(--accent); }
.alert-card.sev-moderate{ background: rgba(200,160,0,0.12); border-color: #c8a000; }
.alert-card.sev-minor { background: rgba(42,107,138,0.12); border-color: var(--cool); }
.alert-card.sev-unknown { background: var(--paper-dark); border-color: var(--border); }
.alert-title { font-weight: 700; margin-bottom: 0.15rem; }
.alert-meta { font-size: 0.75rem; color: var(--ink-light); }
/* Trend arrows */
.stat-trend { font-size: 0.85rem; margin-left: 0.25rem; }
.trend-up { color: #e07848; }
.trend-down { color: #5ab0d8; }
.trend-flat { color: var(--ink-light); opacity: 0.5; }
/* Offline banner */
.offline-banner { background: rgba(194,90,42,0.12); border: 1px solid var(--accent); border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; color: var(--accent); display: none; }
/* Monthly summary */
.monthly-table-wrap { overflow-x: auto; margin-top: 0.75rem; }
.monthly-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.monthly-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); }
.monthly-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
.monthly-table tr:last-child td { border-bottom: none; }
.monthly-table tr:hover td { background: var(--paper-darker); }
/* Heatmap */
.heatmap-wrap { overflow-x: auto; padding-bottom: 0.5rem; }
.heatmap { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); gap: 3px; }
.heatmap-cell { width: 14px; height: 14px; border-radius: 3px; background: var(--border); position: relative; }
.heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: #fff; padding: 3px 7px; border-radius: 5px; font-size: 11px; white-space: nowrap; pointer-events: none; z-index: 10; }
.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.72rem; color: var(--ink-light); }
.heatmap-legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
/* Settings sections */
.settings-section-hdr { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-light); margin: 1.25rem 0 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); }
.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
@media (max-width: 480px) { .threshold-row { grid-template-columns: 1fr; } }
.hidden { display: none !important; }
/* ── Section cards ── */
.section-card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; }
.section-card-hdr { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 1.1rem; }
/* ── Hero ── */
.hero { display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap; }
.hero-main { flex: 0 0 auto; min-width: 160px; }
.hero-temp-row { display: flex; align-items: baseline; gap: 0.25rem; }
.hero-temp { font-size: 5.5rem; font-weight: 800; line-height: 1; letter-spacing: -0.03em; transition: color 0.4s; }
.hero-temp-unit { font-size: 2rem; font-weight: 300; color: var(--ink-light); align-self: flex-start; margin-top: 0.6rem; }
.hero-sub-line { font-size: 0.92rem; color: var(--ink-light); margin-top: 0.3rem; }
.hero-grid { flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem 1.5rem; min-width: 260px; align-content: start; }
.hero-stat-label { font-size: 0.67rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin-bottom: 0.1rem; }
.hero-stat-val { font-size: 1.45rem; font-weight: 700; line-height: 1.15; }
.hero-stat-unit { font-size: 0.72rem; color: var(--ink-light); }
@media (max-width: 640px) {
.hero { flex-direction: column; gap: 1.25rem; }
.hero-temp { font-size: 4.5rem; }
.hero-grid { grid-template-columns: repeat(3, 1fr); min-width: 0; width: 100%; }
.hero-stat-val { font-size: 1.15rem; }
}
/* ── Today bar ── */
.today-bar { display: flex; align-items: center; gap: 0.75rem; background: var(--paper-dark); border: 1px solid var(--border); border-radius: 12px; padding: 0.65rem 1.1rem; margin-bottom: 1rem; font-size: 0.88rem; flex-wrap: wrap; }
.today-label { font-size: 0.67rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-light); font-weight: 700; margin-right: 0.1rem; flex-shrink: 0; }
.today-item { display: flex; align-items: baseline; gap: 0.25rem; }
.today-icon { font-size: 0.8rem; }
.today-val { font-weight: 700; }
.today-val.hi { color: var(--accent); }
.today-val.lo { color: var(--cool); }
.today-sep { width: 1px; height: 1.1em; background: var(--border); flex-shrink: 0; }
.today-lbl { font-size: 0.75rem; color: var(--ink-light); }
/* ── Ecobee section ── */
/* ── Tile color tints ── */
.stat-hot { border-color: rgba(220,80,30,0.55) !important; background: rgba(220,80,30,0.07) !important; }
.stat-warm { border-color: rgba(200,155,0,0.5) !important; background: rgba(200,155,0,0.06) !important; }
.stat-cool { border-color: rgba(42,140,200,0.5) !important; background: rgba(42,140,200,0.07) !important; }
.stat-cold { border-color: rgba(60,100,200,0.55) !important; background: rgba(60,100,200,0.09) !important; }
.stat-raining { border-color: rgba(42,107,138,0.6) !important; background: rgba(42,107,138,0.09) !important; }
.stat-uv-mod { border-color: rgba(200,155,0,0.5) !important; background: rgba(200,155,0,0.07) !important; }
.stat-uv-high { border-color: rgba(210,80,20,0.55) !important; background: rgba(210,80,20,0.08) !important; }
.stat-uv-xhigh { border-color: rgba(150,0,70,0.5) !important; background: rgba(150,0,70,0.08) !important; }
/* ── Sparklines ── */
canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem; opacity: 0.65; }
/* ── Secondary stats strip ── */
.stats.secondary { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); margin-bottom: 1rem; }
/* ── Updated chart look ── */
.chart-wrap { position: relative; height: 240px; }
.chart-card.wide .chart-wrap { height: 160px; }
.chart-card { transition: box-shadow 0.15s; }
.chart-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
</style>
</head>
<body>
<div class="header">
<div>
<h1>Station Dashboard</h1>
<div class="meta" id="headerMeta">Loading…</div>
</div>
<div class="header-right">
<span class="station-name" id="stationName"></span>
<a href="/" class="logout">App</a>
<a href="/dashboard/logout" class="logout">Sign out</a>
</div>
</div>
<div class="page">
<div class="offline-banner" id="offlineBanner"></div>
<div id="alertsSection" style="margin-bottom:0"></div>
<!-- Hero: Current Conditions -->
<div class="section-card">
<div class="section-card-hdr">Current Conditions</div>
<div class="hero">
<div class="hero-main">
<div class="hero-temp-row">
<div class="hero-temp" id="sTempF">—</div>
<div class="hero-temp-unit">°F</div>
</div>
<div class="hero-sub-line">Feels like <span id="sFeels">—</span>°&thinsp;F</div>
<div class="hero-sub-line" id="heroWindLine" style="margin-top:0.15rem"></div>
</div>
<div class="hero-grid">
<div>
<div class="hero-stat-label">Humidity</div>
<div class="hero-stat-val" id="sHum">—<span class="stat-trend" id="trHum"></span></div>
<div class="hero-stat-unit">%</div>
<canvas class="sparkline" id="spHum"></canvas>
</div>
<div>
<div class="hero-stat-label">Dew Point</div>
<div class="hero-stat-val" id="sDew">—<span class="stat-trend" id="trDew"></span></div>
<div class="hero-stat-unit">°F</div>
</div>
<div>
<div class="hero-stat-label">Pressure</div>
<div class="hero-stat-val" id="sPres">—<span class="stat-trend" id="trPres"></span></div>
<div class="hero-stat-unit">inHg&thinsp;<span id="sTrend" style="font-size:0.9rem"></span></div>
<canvas class="sparkline" id="spPres"></canvas>
</div>
<div>
<div class="hero-stat-label">UV Index</div>
<div class="hero-stat-val" id="sUV">—<span class="stat-trend" id="trUV"></span></div>
<div class="hero-stat-unit"></div>
</div>
<div>
<div class="hero-stat-label">Solar Rad.</div>
<div class="hero-stat-val" id="sSolar">—<span class="stat-trend" id="trSolar"></span></div>
<div class="hero-stat-unit">W/m²</div>
<canvas class="sparkline" id="spSolar"></canvas>
</div>
<div>
<div class="hero-stat-label">Battery</div>
<div class="hero-stat-val" id="sBatt">—</div>
<div class="hero-stat-unit"></div>
</div>
</div>
</div>
</div>
<!-- Today at a glance -->
<div class="today-bar" id="todayBar" style="display:none">
<span class="today-label">Today</span>
<div class="today-item"><span class="today-icon">↑</span><span class="today-val hi" id="todayHigh">—</span><span class="today-lbl">°F high</span></div>
<div class="today-item"><span class="today-icon">↓</span><span class="today-val lo" id="todayLow">—</span><span class="today-lbl">°F low</span></div>
<div class="today-sep"></div>
<div class="today-item"><span class="today-icon">💧</span><span class="today-val" id="todayRain">—</span><span class="today-lbl">" rain</span></div>
<div class="today-item"><span class="today-icon">💨</span><span class="today-val" id="todayGust">—</span><span class="today-lbl"> mph peak gust</span></div>
</div>
<!-- Wind section -->
<div class="section-card" style="margin-bottom:1rem">
<div class="section-card-hdr">Wind</div>
<div class="stats secondary" style="margin:0">
<div class="stat" id="statWindTile">
<div class="stat-label">Speed</div>
<div class="stat-val" id="sWind">—<span class="stat-trend" id="trWind"></span></div>
<div class="stat-unit">mph</div>
<div class="stat-unit" id="sWindDir" style="color:var(--ink-light)"></div>
<canvas class="sparkline" id="spWind"></canvas>
</div>
<div class="stat"><div class="stat-label">Gust</div><div class="stat-val" id="sGust">—</div><div class="stat-unit">mph</div></div>
<div class="stat"><div class="stat-label">Peak Gust Today</div><div class="stat-val" id="sMaxGust">—</div><div class="stat-unit">mph</div></div>
</div>
</div>
<!-- Precipitation section -->
<div class="section-card" style="margin-bottom:1rem">
<div class="section-card-hdr">Precipitation</div>
<div class="stats secondary" style="margin:0">
<div class="stat" id="statRainTile"><div class="stat-label">Today</div><div class="stat-val" id="sRain">—</div><div class="stat-unit">in</div></div>
<div class="stat" id="statRainRateTile"><div class="stat-label">Rate</div><div class="stat-val" id="sRainRate">—</div><div class="stat-unit">in/hr</div></div>
<div class="stat"><div class="stat-label">7 Days</div><div class="stat-val" id="sRain7d">—</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">30 Days</div><div class="stat-val" id="sRain30d">—</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">Year to Date</div><div class="stat-val" id="sRainYTD">—</div><div class="stat-unit">in</div></div>
<div class="stat" id="sEventRainTile" style="display:none"><div class="stat-label">Event</div><div class="stat-val" id="sEventRain">—</div><div class="stat-unit">in</div></div>
<div class="stat" id="sTotalRainTile" style="display:none"><div class="stat-label">All Time</div><div class="stat-val" id="sTotalRain">—</div><div class="stat-unit">in</div></div>
<div class="stat" id="sStreakTile" style="display:none">
<div class="stat-label">Streak</div>
<div class="stat-val" id="sStreak">—</div>
<div class="stat-unit" id="sStreakNote"></div>
</div>
</div>
</div>
<!-- Climate analytics -->
<div class="section-card" style="margin-bottom:1rem">
<div class="section-card-hdr">Climate</div>
<div class="stats secondary" style="margin:0">
<div class="stat"><div class="stat-label">Heating DD</div><div class="stat-val" id="sHDD">—</div><div class="stat-unit">this month</div></div>
<div class="stat"><div class="stat-label">Cooling DD</div><div class="stat-val" id="sCDD">—</div><div class="stat-unit">this month</div></div>
<div class="stat" id="sAnomalyTile" style="display:none">
<div class="stat-label">Temp vs Normal</div>
<div class="stat-val" id="sAnomaly">—</div>
<div class="stat-unit" id="sAnomalyNote"></div>
</div>
</div>
</div>
<!-- Indoor (shown only when PWS provides indoor sensor data) -->
<div class="section-card hidden" id="indoorSection" style="margin-bottom:1rem">
<div class="section-card-hdr" style="display:flex;align-items:center;justify-content:space-between">
Indoor
<a href="https://www.ecobee.com/consumerportal/index.html#/devices/thermostats/440785466129" target="_blank" rel="noopener" style="font-size:0.75rem;font-weight:400;text-transform:none;letter-spacing:0;color:var(--cool)">Ecobee →</a>
</div>
<div class="stats secondary" style="margin:0">
<div class="stat">
<div class="stat-label">Temperature</div>
<div class="stat-val" id="sTempIn">—</div>
<div class="stat-unit">°F</div>
</div>
<div class="stat">
<div class="stat-label">Humidity</div>
<div class="stat-val" id="sHumIn">—</div>
<div class="stat-unit">%</div>
</div>
</div>
</div>
<!-- Range selector -->
<div class="range-bar">
<span>History:</span>
<button class="range-btn" data-range="1h">1h</button>
<button class="range-btn" data-range="6h">6h</button>
<button class="range-btn active" data-range="24h">24h</button>
<button class="range-btn" data-range="7d">7d</button>
<button class="range-btn" data-range="30d">30d</button>
<button class="range-btn" data-range="90d">90d</button>
<button class="range-btn" data-range="1y">1y</button>
<button class="range-btn" data-range="2y">2y</button>
<button class="range-btn" data-range="3y">3y</button>
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
</div>
<div class="updated" id="updatedLine"></div>
<!-- Charts -->
<div id="chartsArea">
<div class="status" id="loadingMsg">Loading history…</div>
</div>
<!-- Records -->
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
<summary>All-Time Station Records</summary>
<div class="settings-body" id="recordsBody">
<div class="status">Loading records…</div>
</div>
</details>
<!-- Monthly Summary -->
<details class="settings-panel" id="monthlyPanel" style="margin-top:1rem">
<summary>Monthly Summary</summary>
<div class="settings-body" id="monthlyBody">
<div class="status">Loading…</div>
</div>
</details>
<!-- Temperature Heatmap -->
<details class="settings-panel" id="heatmapPanel" style="margin-top:1rem">
<summary>Temperature Calendar</summary>
<div class="settings-body" id="heatmapBody">
<div class="status">Loading…</div>
</div>
</details>
<!-- Settings -->
<details class="settings-panel" id="settingsPanel">
<summary>Station API Keys</summary>
<div class="settings-body">
<p id="keysStatus" class="keys-status"></p>
<div class="settings-grid">
<div>
<label>Ambient API Key</label>
<input id="inputApiKey" type="password" placeholder="Enter API key" autocomplete="off">
</div>
<div>
<label>Application Key</label>
<input id="inputAppKey" type="password" placeholder="Enter application key" autocomplete="off">
</div>
</div>
<div class="settings-actions">
<button id="saveKeysBtn">Save Keys</button>
<span id="saveMsg" class="save-msg"></span>
</div>
<p class="keys-hint">Find these at <strong>ambientweather.net → My Account → API Keys</strong>. Keys are stored on the server only — never exposed to the browser.</p>
<hr class="settings-divider">
<div class="backfill-header" style="margin-bottom:0.75rem">
<div>
<div class="chart-title" style="margin-bottom:0.25rem">Export Data</div>
<p class="keys-hint" style="margin:0">Download your station history as a CSV spreadsheet.</p>
</div>
<div class="backfill-controls">
<select id="exportRange">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
<option value="1y">1 year</option>
<option value="2y">2 years</option>
<option value="3y">3 years</option>
</select>
<a id="exportBtn" class="export-link" href="#" target="_blank">Download CSV</a>
</div>
</div>
<hr class="settings-divider">
<div class="backfill-section">
<div class="backfill-header">
<div>
<div class="chart-title" style="margin-bottom:0.25rem">Import Historical Data</div>
<p class="keys-hint" style="margin:0">Pull existing data from Ambient's servers into your local records. Ambient keeps ~1 year of history.</p>
</div>
<div class="backfill-controls">
<select id="backfillDays">
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
</select>
<button id="backfillBtn">Import</button>
</div>
</div>
<div id="backfillProgress" class="backfill-progress" style="display:none">
<div class="progress-bar-wrap"><div class="progress-bar" id="progressBar"></div></div>
<div class="progress-text" id="progressText"></div>
</div>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Alert Thresholds (Station)</div>
<p class="keys-hint" style="margin:0 0 0.75rem">Leave blank to disable. These extend your existing push/ntfy alert subscriptions.</p>
<div class="threshold-row">
<div><label>High Temp (°F)</label><input id="thrTempHigh" type="number" placeholder="e.g. 95" autocomplete="off"></div>
<div><label>Low Temp (°F)</label><input id="thrTempLow" type="number" placeholder="e.g. 35" autocomplete="off"></div>
<div><label>Wind Gust (mph)</label><input id="thrWind" type="number" placeholder="e.g. 40" autocomplete="off"></div>
<div><label>Rain Rate (in/hr)</label><input id="thrRainRate" type="number" step="0.01" placeholder="e.g. 0.5" autocomplete="off"></div>
</div>
<div class="settings-actions">
<button id="saveThreshBtn">Save Thresholds</button>
<span id="threshMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Station Display</div>
<div style="margin-bottom:0.75rem">
<label>Station Name (shown on public conditions page)</label>
<input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off">
</div>
<div class="settings-actions">
<button id="saveNameBtn">Save Name</button>
<span id="nameMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Station Location (for NWS Alerts)</div>
<p class="keys-hint" style="margin:0 0 0.75rem">Used to fetch active NWS weather alerts for your area. If you have push notifications enabled, your saved location is used automatically.</p>
<div class="threshold-row">
<div><label>Latitude</label><input id="inputLat" type="number" step="0.0001" placeholder="e.g. 38.8977" autocomplete="off"></div>
<div><label>Longitude</label><input id="inputLon" type="number" step="0.0001" placeholder="e.g. -77.0365" autocomplete="off"></div>
</div>
<div class="settings-actions">
<button id="saveLocBtn">Save Location</button>
<span id="locMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Change Password</div>
<div class="settings-grid">
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
</div>
<div class="settings-actions">
<button id="changePwBtn">Change Password</button>
<span id="pwMsg" class="save-msg"></span>
</div>
</div>
</details>
</div>
<script>
const RANGE_LABELS = {
'1h': 'HH:mm', '6h': 'HH:mm', '24h': 'HH:mm',
'7d': 'MMM dd', '30d': 'MMM dd'
};
let currentRange = '24h';
let charts = {};
// ── Formatting helpers ──────────────────────────────────────────────────────
function fmtTime(ts, range) {
const d = new Date(ts);
if (['7d','30d','90d','1y','2y','3y'].includes(range)) {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
}
function cardinal(deg) {
const dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
return dirs[Math.round(deg / 22.5) % 16];
}
// ── Chart factory ───────────────────────────────────────────────────────────
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#c25a2a';
const cool = getComputedStyle(document.documentElement).getPropertyValue('--cool').trim() || '#2a6b8a';
function chartColors() {
const style = getComputedStyle(document.documentElement);
return {
accent: style.getPropertyValue('--accent').trim(),
cool: style.getPropertyValue('--cool').trim(),
ink: style.getPropertyValue('--ink').trim(),
inkLight: style.getPropertyValue('--ink-light').trim(),
border: style.getPropertyValue('--border').trim(),
};
}
function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
const c = chartColors();
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
if (charts[canvasId]) { charts[canvasId].destroy(); }
const defaults = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.75)',
titleFont: { size: 11 }, bodyFont: { size: 12 },
callbacks: {
title: items => items[0]?.label || '',
}
}
},
scales: {
x: {
ticks: { color: c.inkLight, font: { size: 10 }, maxTicksLimit: 8, maxRotation: 0 },
grid: { color: c.border + '44' }
},
y: {
ticks: { color: c.inkLight, font: { size: 11 } },
grid: { color: c.border + '44' },
title: yLabel ? { display: true, text: yLabel, color: c.inkLight, font: { size: 10 } } : undefined,
}
}
};
const mergedOpts = deepMerge(defaults, opts);
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
return charts[canvasId];
}
function deepMerge(a, b) {
const out = { ...a };
for (const k of Object.keys(b || {})) {
if (b[k] && typeof b[k] === 'object' && !Array.isArray(b[k]) && a[k] && typeof a[k] === 'object') {
out[k] = deepMerge(a[k], b[k]);
} else {
out[k] = b[k];
}
}
return out;
}
function lineDataset(label, data, color, dashed = false) {
return {
label, data, borderColor: color,
backgroundColor: dashed ? color + '00' : color + '18',
borderWidth: 1.75, pointRadius: 0, pointHoverRadius: 4,
borderDash: dashed ? [4, 3] : [], tension: 0.35,
fill: dashed ? false : 'origin',
};
}
function barDataset(label, data, color) {
return { label, data, backgroundColor: color + 'bb', borderColor: color, borderWidth: 1, borderRadius: 2 };
}
// ── Wind Rose ───────────────────────────────────────────────────────────────
function drawWindRose(canvas, readings) {
const buckets = new Array(16).fill(0);
for (const r of readings) {
if (r.winddir == null) continue;
buckets[Math.round(r.winddir / 22.5) % 16]++;
}
const max = Math.max(...buckets, 1);
const c = canvas.getContext('2d');
// Context is pre-scaled by DPR — use CSS dimensions for all coordinates
const dpr = window.devicePixelRatio || 1;
const W = canvas.width / dpr, H = canvas.height / dpr;
const cx = W / 2, cy = H / 2;
const R = Math.min(cx, cy) - 24;
const style = getComputedStyle(document.documentElement);
const colBorder = style.getPropertyValue('--border').trim();
const colAccent = style.getPropertyValue('--accent').trim();
const colInk = style.getPropertyValue('--ink').trim();
const colInkLight = style.getPropertyValue('--ink-light').trim();
c.clearRect(0, 0, W, H);
// grid circles
for (let i = 1; i <= 4; i++) {
c.beginPath();
c.arc(cx, cy, R * i / 4, 0, Math.PI * 2);
c.strokeStyle = colBorder + '66';
c.lineWidth = 0.5;
c.stroke();
}
// cardinal lines
['N','E','S','W'].forEach((dir, i) => {
const a = i * Math.PI / 2 - Math.PI / 2;
c.beginPath();
c.moveTo(cx, cy);
c.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
c.strokeStyle = colBorder + '88';
c.lineWidth = 0.5;
c.stroke();
const tx = cx + Math.cos(a) * (R + 12);
const ty = cy + Math.sin(a) * (R + 12);
c.fillStyle = colInkLight;
c.font = '10px sans-serif';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(dir, tx, ty);
});
// petals
const sliceAngle = (Math.PI * 2) / 16;
for (let i = 0; i < 16; i++) {
const a = i * sliceAngle - Math.PI / 2 - sliceAngle / 2;
const len = (buckets[i] / max) * R;
if (len < 1) continue;
c.beginPath();
c.moveTo(cx, cy);
c.arc(cx, cy, len, a, a + sliceAngle);
c.closePath();
c.fillStyle = colAccent + 'cc';
c.fill();
c.strokeStyle = colAccent;
c.lineWidth = 0.5;
c.stroke();
}
// center dot
c.beginPath();
c.arc(cx, cy, 3, 0, Math.PI * 2);
c.fillStyle = colInk;
c.fill();
}
// ── Render charts ────────────────────────────────────────────────────────────
function renderCharts(readings, range, yoy = null) {
const area = document.getElementById('chartsArea');
if (!readings.length) {
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
return;
}
const labels = readings.map(r => fmtTime(r.dateutc, range));
const c = chartColors();
// downsample for 30d to keep chart manageable
let pts = readings;
if (readings.length > 720) {
const step = Math.ceil(readings.length / 720);
pts = readings.filter((_, i) => i % step === 0);
}
const lbls = pts.map(r => fmtTime(r.dateutc, range));
function field(arr, key) { return arr.map(r => r[key] != null ? r[key] : null); }
area.innerHTML = `
<div class="charts">
<!-- Temperature -->
<div class="chart-card">
<div class="chart-title">Temperature & Feels Like</div>
<div class="chart-wrap"><canvas id="cTemp"></canvas></div>
</div>
<!-- Humidity & Dew -->
<div class="chart-card">
<div class="chart-title">Humidity & Dew Point</div>
<div class="chart-wrap"><canvas id="cHum"></canvas></div>
</div>
<!-- Wind speed -->
<div class="chart-card">
<div class="chart-title">Wind Speed & Gusts</div>
<div class="chart-wrap"><canvas id="cWind"></canvas></div>
</div>
<!-- Wind rose -->
<div class="chart-card">
<div class="chart-title">Wind Direction</div>
<div class="chart-wrap" id="roseWrap" style="display:flex;align-items:center;justify-content:center;">
<canvas id="windRoseCanvas" width="200" height="200"></canvas>
</div>
</div>
<!-- Pressure -->
<div class="chart-card wide">
<div class="chart-title">Barometric Pressure (inHg)</div>
<div class="chart-wrap"><canvas id="cPres"></canvas></div>
</div>
<!-- Rain -->
<div class="chart-card">
<div class="chart-title">Rain Rate (in/hr)</div>
<div class="chart-wrap"><canvas id="cRainRate"></canvas></div>
</div>
<!-- Rain accum -->
<div class="chart-card">
<div class="chart-title">Daily Rain Accumulation (in)</div>
<div class="chart-wrap"><canvas id="cRainAccum"></canvas></div>
</div>
<!-- UV + Solar -->
<div class="chart-card">
<div class="chart-title">UV Index</div>
<div class="chart-wrap"><canvas id="cUV"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-title">Solar Radiation (W/m²)</div>
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
</div>
</div>
`;
const tempDatasets = [
lineDataset('Temp °F', field(pts, 'tempf'), c.accent),
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true),
];
if (yoy?.prior?.length) {
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
}
makeChart('cTemp', 'line', tempDatasets, lbls, '°F');
makeChart('cHum', 'line',
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)],
lbls, null);
makeChart('cWind', 'line',
[lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool),
lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)],
lbls, 'mph');
const hasPres = pts.some(r => r.baromrelin != null);
if (hasPres) {
makeChart('cPres', 'line',
[lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent),
lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)],
lbls, 'inHg');
}
makeChart('cRainRate', 'bar',
[barDataset('Rain Rate', field(pts, 'rainratein'), c.cool)],
lbls, 'in/hr');
makeChart('cRainAccum', 'line',
[lineDataset('Daily Rain', field(pts, 'dailyrainin'), c.cool)],
lbls, 'in', { scales: { y: { min: 0 } } });
makeChart('cUV', 'line',
[lineDataset('UV Index', field(pts, 'uv'), c.accent)],
lbls, null, { scales: { y: { min: 0 } } });
makeChart('cSolar', 'line',
[lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)],
lbls, 'W/m²', { scales: { y: { min: 0 } } });
// Today bar + sparklines use full readings (not downsampled pts)
updateTodayBar(readings);
updateSparklines(readings);
// Wind rose
const roseCanvas = document.getElementById('windRoseCanvas');
if (roseCanvas) {
const dpr = window.devicePixelRatio || 1;
roseCanvas.width = 200 * dpr;
roseCanvas.height = 200 * dpr;
roseCanvas.style.width = '200px';
roseCanvas.style.height = '200px';
roseCanvas.getContext('2d').scale(dpr, dpr);
drawWindRose(roseCanvas, readings);
}
}
// ── Update current stats ─────────────────────────────────────────────────────
async function updateLatest() {
try {
const r = await fetch('/api/station/latest');
if (!r.ok) return;
const d = await r.json();
if (!d) return;
const set = (id, val, digits = 1) => {
const el = document.getElementById(id);
if (el) el.textContent = val != null ? (typeof val === 'number' ? val.toFixed(digits) : val) : '—';
};
set('sTempF', d.tempf);
set('sFeels', d.feelsLike ?? d.tempf);
set('sHum', d.humidity, 0);
set('sDew', d.dewPoint);
set('sWind', d.windspeedmph);
set('sGust', d.windgustmph);
set('sPres', d.baromrelin, 2);
set('sRain', d.dailyrainin, 2);
set('sRainRate', d.rainratein, 2);
set('sUV', d.uv, 0);
set('sSolar', d.solarradiation, 0);
set('sBatt', d.battout != null ? (d.battout === 1 ? 'OK' : 'Low') : '—', 0);
if (d.maxdailygust != null) set('sMaxGust', d.maxdailygust);
// Indoor sensor (Ambient stations with indoor probe)
if (d.tempinf != null) {
set('sTempIn', d.tempinf);
set('sHumIn', d.humidityin, 0);
const sec = document.getElementById('indoorSection');
if (sec) sec.classList.remove('hidden');
}
// Ambient rain accumulators
if (d.eventrainin != null && d.eventrainin > 0) {
set('sEventRain', d.eventrainin, 2);
const evTile = document.getElementById('sEventRainTile');
if (evTile) evTile.style.display = '';
}
if (d.totalrainin != null) {
set('sTotalRain', d.totalrainin, 2);
const totTile = document.getElementById('sTotalRainTile');
if (totTile) totTile.style.display = '';
}
// Hero wind line
const heroWind = document.getElementById('heroWindLine');
if (heroWind && d.windspeedmph != null) {
const dir = d.winddir != null ? cardinal(d.winddir) : '';
heroWind.textContent = d.windspeedmph > 0
? `${d.windspeedmph.toFixed(1)} mph ${dir} wind`
: 'Calm winds';
}
// Wind direction on secondary tile
const windDirEl = document.getElementById('sWindDir');
if (windDirEl) windDirEl.textContent = d.winddir != null ? cardinal(d.winddir) : '—';
colorTiles(d);
const name = document.getElementById('stationName');
if (name && d.PASSKEY) name.textContent = '';
const meta = document.getElementById('headerMeta');
if (meta && d.dateutc) {
meta.textContent = 'Updated ' + new Date(d.dateutc).toLocaleTimeString();
}
updateOfflineBanner(d.dateutc || null);
} catch {}
}
// ── Load history ─────────────────────────────────────────────────────────────
async function loadHistory(range) {
const area = document.getElementById('chartsArea');
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
try {
const r = await fetch(`/api/station/history?range=${range}`);
if (r.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error('Failed');
const data = await r.json();
renderCharts(data, range, yoyEnabled ? yoyData : null);
const line = document.getElementById('updatedLine');
if (line) {
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
line.textContent = `${data.length} readings · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
}
} catch (e) {
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.querySelectorAll('.range-btn[data-range]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.range-btn[data-range]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentRange = btn.dataset.range;
yoyData = null; yoyEnabled = false; yoyBtn.classList.remove('active');
updateYoyVisibility(currentRange);
loadHistory(currentRange);
});
});
document.getElementById('refreshBtn').addEventListener('click', () => {
updateLatest();
loadHistory(currentRange);
});
// Auto-refresh latest + charts every 2 minutes
setInterval(updateLatest, 120000);
setInterval(() => loadHistory(currentRange), 120000);
// ── API key settings ─────────────────────────────────────────────────────────
async function checkKeyStatus() {
try {
const r = await fetch('/api/ambient-config');
if (!r.ok) return;
const { configured } = await r.json();
const el = document.getElementById('keysStatus');
if (configured) {
el.textContent = '✓ API keys are configured';
el.className = 'keys-status ok';
} else {
el.textContent = '⚠ No API keys saved — enter them below to enable station data';
el.className = 'keys-status missing';
document.getElementById('settingsPanel').open = true;
}
} catch {}
}
document.getElementById('saveKeysBtn').addEventListener('click', async () => {
const apiKey = document.getElementById('inputApiKey').value.trim();
const appKey = document.getElementById('inputAppKey').value.trim();
const msg = document.getElementById('saveMsg');
if (!apiKey || !appKey) { msg.textContent = 'Both fields are required.'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/ambient-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey, appKey })
});
if (r.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error('Save failed');
msg.textContent = 'Saved! Data will start recording on the next poll.';
msg.className = 'save-msg';
document.getElementById('inputApiKey').value = '';
document.getElementById('inputAppKey').value = '';
checkKeyStatus();
} catch (e) {
msg.textContent = 'Error saving keys.';
msg.className = 'save-msg err';
}
});
// ── Backfill ─────────────────────────────────────────────────────────────────
let backfillPoller = null;
async function startBackfill() {
const days = document.getElementById('backfillDays').value;
const btn = document.getElementById('backfillBtn');
const progress = document.getElementById('backfillProgress');
btn.disabled = true;
btn.textContent = 'Running…';
progress.style.display = 'block';
document.getElementById('progressBar').style.width = '0%';
document.getElementById('progressText').textContent = 'Starting…';
try {
const r = await fetch('/api/station/backfill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: parseInt(days) })
});
if (r.status === 401) { location.href = '/dashboard/login'; return; }
const data = await r.json();
if (!data.ok) {
document.getElementById('progressText').textContent = data.error || 'Failed to start.';
btn.disabled = false; btn.textContent = 'Import';
return;
}
backfillPoller = setInterval(pollBackfill, 2000);
} catch (e) {
document.getElementById('progressText').textContent = 'Error: ' + e.message;
btn.disabled = false; btn.textContent = 'Import';
}
}
async function pollBackfill() {
try {
const r = await fetch('/api/station/backfill/status');
if (!r.ok) return;
const s = await r.json();
const pct = s.total > 0 ? Math.min(100, Math.round((s.done / s.total) * 100)) : 0;
document.getElementById('progressBar').style.width = pct + '%';
if (s.error) {
document.getElementById('progressText').textContent = 'Error: ' + s.error;
} else if (s.running) {
document.getElementById('progressText').textContent = `${s.done} of ${s.total} days processed · ${s.inserted.toLocaleString()} readings imported`;
} else {
document.getElementById('progressText').textContent = `Done — imported ${s.inserted.toLocaleString()} readings. Refresh charts to see the data.`;
document.getElementById('progressBar').style.width = '100%';
clearInterval(backfillPoller);
backfillPoller = null;
document.getElementById('backfillBtn').disabled = false;
document.getElementById('backfillBtn').textContent = 'Import';
loadHistory(currentRange);
}
} catch {}
}
document.getElementById('backfillBtn').addEventListener('click', startBackfill);
// ── Pressure trend ────────────────────────────────────────────────────────────
async function updatePressureTrend() {
try {
const r = await fetch('/api/station/history?range=6h');
if (!r.ok) return;
const data = await r.json();
if (data.length < 2) return;
const recent = data[data.length - 1]?.baromrelin;
const older = data[Math.max(0, data.length - 19)]?.baromrelin; // ~3h ago (at 1/min, 18 readings = ~18min... adjust for actual density)
const threeHoursAgo = Date.now() - 3 * 3600000;
const baseline = data.find(r => r.dateutc >= threeHoursAgo)?.baromrelin;
if (recent == null || baseline == null) return;
const diff = recent - baseline;
const el = document.getElementById('sTrend');
if (!el) return;
if (diff > 0.02) { el.textContent = '↑'; el.title = `Rising +${diff.toFixed(3)} inHg in 3h`; el.style.color = 'var(--cool)'; }
else if (diff < -0.02) { el.textContent = '↓'; el.title = `Falling ${diff.toFixed(3)} inHg in 3h`; el.style.color = 'var(--accent)'; }
else { el.textContent = '→'; el.title = `Steady (${diff > 0 ? '+' : ''}${diff.toFixed(3)} inHg in 3h)`; el.style.color = 'var(--ink-light)'; }
} catch {}
}
// ── Rain totals + degree days ─────────────────────────────────────────────────
async function updateExtStats() {
try {
const [rt, dd, an] = await Promise.all([
fetch('/api/station/rain-totals').then(r => r.ok ? r.json() : null),
fetch('/api/station/degree-days?rangeMs=' + (30 * 86400000)).then(r => r.ok ? r.json() : null),
fetch('/api/station/anomaly').then(r => r.ok ? r.json() : null),
]);
if (rt) {
const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v != null ? v : '—'; };
s('sRain7d', rt.rain7d);
s('sRain30d', rt.rain30d);
s('sRainYTD', rt.rainYTD);
}
if (dd) {
document.getElementById('sHDD').textContent = dd.hdd;
document.getElementById('sCDD').textContent = dd.cdd;
}
if (an?.available) {
const tile = document.getElementById('sAnomalyTile');
tile.style.display = '';
const diff = an.currentTemp != null && an.mean != null ? (an.currentTemp - an.mean) : null;
document.getElementById('sAnomaly').textContent = an.anomaly === 'above' ? 'Above Normal' : an.anomaly === 'below' ? 'Below Normal' : 'Near Normal';
document.getElementById('sAnomalyNote').textContent = diff != null ? `${diff > 0 ? '+' : ''}${diff.toFixed(1)}°F vs avg` : '';
tile.className = 'stat' + (an.anomaly === 'above' ? ' anomaly-above' : an.anomaly === 'below' ? ' anomaly-below' : '');
}
} catch {}
}
// ── Records section ───────────────────────────────────────────────────────────
function fmtDate(ts) {
if (!ts) return '—';
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
async function loadRecords() {
const panel = document.getElementById('recordsPanel');
if (!panel) return;
panel.addEventListener('toggle', async () => {
if (!panel.open) return;
const body = document.getElementById('recordsBody');
if (body.querySelector('.records-grid')) return; // already loaded
try {
const r = await fetch('/api/station/records');
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)}` : '';
body.innerHTML = `
<p style="font-size:0.8rem;color:var(--ink-light);margin-bottom:0.75rem">${since}</p>
<div class="records-grid">
<div class="record-item"><div class="record-label">Hottest</div><div class="record-val">${d.highTemp != null ? d.highTemp.toFixed(1) + '°F' : '—'}</div><div class="record-date">${fmtDate(d.highTempDate)}</div></div>
<div class="record-item"><div class="record-label">Coldest</div><div class="record-val">${d.lowTemp != null ? d.lowTemp.toFixed(1) + '°F' : '—'}</div><div class="record-date">${fmtDate(d.lowTempDate)}</div></div>
<div class="record-item"><div class="record-label">Highest Gust</div><div class="record-val">${d.highGust != null ? d.highGust.toFixed(1) + ' mph' : '—'}</div><div class="record-date">${fmtDate(d.highGustDate)}</div></div>
<div class="record-item"><div class="record-label">Highest Rain Rate</div><div class="record-val">${d.highRainRate != null ? d.highRainRate.toFixed(2) + ' in/hr' : '—'}</div><div class="record-date">${fmtDate(d.highRainRateDate)}</div></div>
<div class="record-item"><div class="record-label">Wettest Day</div><div class="record-val">${d.highDailyRain != null ? d.highDailyRain.toFixed(2) + '"' : '—'}</div><div class="record-date">${fmtDate(d.highDailyRainDate)}</div></div>
<div class="record-item"><div class="record-label">Highest UV</div><div class="record-val">${d.highUV != null ? d.highUV.toFixed(0) : '—'}</div><div class="record-date">${fmtDate(d.highUVDate)}</div></div>
<div class="record-item"><div class="record-label">Peak Solar</div><div class="record-val">${d.highSolar != null ? d.highSolar.toFixed(0) + ' W/m²' : '—'}</div><div class="record-date">${fmtDate(d.highSolarDate)}</div></div>
<div class="record-item"><div class="record-label">Highest Pressure</div><div class="record-val">${d.highPressure != null ? d.highPressure.toFixed(2) + ' inHg' : '—'}</div><div class="record-date">${fmtDate(d.highPressureDate)}</div></div>
<div class="record-item"><div class="record-label">Lowest Pressure</div><div class="record-val">${d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—'}</div><div class="record-date">${fmtDate(d.lowPressureDate)}</div></div>
<div class="record-item"><div class="record-label">Highest Humidity</div><div class="record-val">${d.highHumidity != null ? d.highHumidity + '%' : '—'}</div><div class="record-date">${fmtDate(d.highHumidityDate)}</div></div>
<div class="record-item"><div class="record-label">Lowest Humidity</div><div class="record-val">${d.lowHumidity != null ? d.lowHumidity + '%' : '—'}</div><div class="record-date">${fmtDate(d.lowHumidityDate)}</div></div>
</div>`;
} catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; }
}, { once: false });
}
// ── Year-over-year ────────────────────────────────────────────────────────────
let yoyEnabled = false;
let yoyData = null;
const yoyBtn = document.getElementById('yoyBtn');
yoyBtn.addEventListener('click', async () => {
yoyEnabled = !yoyEnabled;
yoyBtn.classList.toggle('active', yoyEnabled);
if (yoyEnabled && !yoyData) {
yoyBtn.textContent = '…';
try {
const r = await fetch(`/api/station/yoy?range=${currentRange}`);
yoyData = r.ok ? await r.json() : null;
} catch {}
yoyBtn.textContent = 'YoY';
}
loadHistory(currentRange);
});
// Show YoY button only for long ranges
function updateYoyVisibility(range) {
const longRange = ['1y','2y','3y'].includes(range);
yoyBtn.style.display = longRange ? '' : 'none';
if (!longRange) { yoyEnabled = false; yoyData = null; yoyBtn.classList.remove('active'); }
}
// Patch loadHistory to pass yoyData to renderCharts
const _origLoadHistory = loadHistory;
// Override renderCharts to accept yoy
const _origRenderCharts = renderCharts;
// ── CSV Export ────────────────────────────────────────────────────────────────
function updateExportLink() {
const sel = document.getElementById('exportRange');
const btn = document.getElementById('exportBtn');
if (sel && btn) btn.href = `/api/station/export.csv?range=${sel.value}`;
}
document.getElementById('exportRange')?.addEventListener('change', updateExportLink);
updateExportLink();
// ── Trend arrows ─────────────────────────────────────────────────────────────
async function updateTrends() {
try {
const r = await fetch('/api/station/trend');
if (!r.ok) return;
const t = await r.json();
const arrows = { up: '▲', down: '▼', flat: '–' };
const cls = { up: 'trend-up', down: 'trend-down', flat: 'trend-flat' };
const map = { tempf: 'trTempF', humidity: 'trHum', dewPoint: 'trDew', windspeedmph: 'trWind', baromrelin: 'trPres', uv: 'trUV', solarradiation: 'trSolar' };
for (const [key, elId] of Object.entries(map)) {
const el = document.getElementById(elId);
if (!el || !t[key]) continue;
el.textContent = arrows[t[key]] || '';
el.className = 'stat-trend ' + (cls[t[key]] || '');
}
} catch {}
}
// ── Offline banner ────────────────────────────────────────────────────────────
function updateOfflineBanner(lastDateutc) {
const banner = document.getElementById('offlineBanner');
if (!banner) return;
if (!lastDateutc) { banner.style.display = 'none'; return; }
const mins = Math.round((Date.now() - lastDateutc) / 60000);
if (mins > 15) {
banner.textContent = `⚠ Station last reported ${mins} minutes ago — may be offline`;
banner.style.display = 'block';
} else {
banner.style.display = 'none';
}
}
// ── Monthly summary ───────────────────────────────────────────────────────────
const monthlyPanel = document.getElementById('monthlyPanel');
if (monthlyPanel) {
monthlyPanel.addEventListener('toggle', async () => {
if (!monthlyPanel.open) return;
const body = document.getElementById('monthlyBody');
if (body.querySelector('table')) return;
try {
const r = await fetch('/api/station/monthly-summary');
if (!r.ok) throw new Error();
const data = await r.json();
if (!data.length) { body.innerHTML = '<div class="status">No monthly data yet.</div>'; return; }
const rows = data.slice().reverse().map(m => {
const [yr, mo] = m.month.split('-');
const label = new Date(+yr, +mo - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
return `<tr>
<td>${label}</td>
<td>${m.avgTemp != null ? m.avgTemp.toFixed(1) + '°F' : '—'}</td>
<td>${m.highTemp != null ? m.highTemp.toFixed(1) + '°F' : '—'}</td>
<td>${m.highGust != null ? m.highGust + ' mph' : '—'}</td>
<td>${m.totalRain != null ? m.totalRain.toFixed(2) + '"' : '—'}</td>
<td style="color:var(--ink-light)">${m.readings.toLocaleString()}</td>
</tr>`;
}).join('');
body.innerHTML = `<div class="monthly-table-wrap"><table class="monthly-table">
<thead><tr><th>Month</th><th>Avg Temp</th><th>High Temp</th><th>Max Gust</th><th>Total Rain</th><th>Readings</th></tr></thead>
<tbody>${rows}</tbody></table></div>`;
} catch { document.getElementById('monthlyBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
}, { once: false });
}
// ── Temperature heatmap ───────────────────────────────────────────────────────
const heatmapPanel = document.getElementById('heatmapPanel');
if (heatmapPanel) {
heatmapPanel.addEventListener('toggle', async () => {
if (!heatmapPanel.open) return;
const body = document.getElementById('heatmapBody');
if (body.querySelector('.heatmap-wrap')) return;
try {
const r = await fetch('/api/station/history?range=1y');
if (!r.ok) throw new Error();
const readings = await r.json();
if (!readings.length) { body.innerHTML = '<div class="status">No data yet.</div>'; return; }
// Daily high temp
const byDay = {};
for (const rd of readings) {
if (rd.tempf == null || !rd.dateutc) continue;
const day = new Date(rd.dateutc).toISOString().slice(0, 10);
if (byDay[day] == null || rd.tempf > byDay[day]) byDay[day] = rd.tempf;
}
// Color scale: cold = cool blue, hot = accent orange
function heatColor(t) {
if (t == null) return 'var(--border)';
const norm = Math.max(0, Math.min(1, (t - 20) / 90));
const r = Math.round(42 + norm * (224 - 42));
const g = Math.round(107 + norm * (120 - 107));
const b = Math.round(138 + norm * (42 - 138));
return `rgb(${r},${g},${b})`;
}
// Build grid: iterate days from 1yr ago to today
const today = new Date(); today.setHours(23, 59, 59, 0);
const start = new Date(today); start.setFullYear(start.getFullYear() - 1); start.setDate(start.getDate() + 1);
// Align to Sunday
const startDow = start.getDay(); // 0=Sun
const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null); // padding
const cur = new Date(start);
while (cur <= today) {
const key = cur.toISOString().slice(0, 10);
cells.push({ date: key, temp: byDay[key] ?? null });
cur.setDate(cur.getDate() + 1);
}
const cellHtml = cells.map(c => {
if (!c) return `<div class="heatmap-cell" style="background:transparent"></div>`;
const t = c.temp;
const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date;
return `<div class="heatmap-cell" style="background:${heatColor(t)}" title="${label}"></div>`;
}).join('');
body.innerHTML = `
<div class="heatmap-wrap"><div class="heatmap">${cellHtml}</div></div>
<div class="heatmap-legend">
<span>Cooler</span>
${[20,35,50,65,80,95,110].map(t => `<div class="heatmap-legend-swatch" style="background:${heatColor(t)}"></div>`).join('')}
<span>Hotter</span>
</div>`;
} catch { document.getElementById('heatmapBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
}, { once: false });
}
// ── Station name ──────────────────────────────────────────────────────────────
async function loadStationName() {
try {
const r = await fetch('/api/dashboard/station-name');
if (!r.ok) return;
const d = await r.json();
const el = document.getElementById('inputStationName');
if (el && d.name) el.value = d.name;
} catch {}
}
document.getElementById('saveNameBtn')?.addEventListener('click', async () => {
const name = document.getElementById('inputStationName')?.value?.trim();
const msg = document.getElementById('nameMsg');
if (!name) { msg.textContent = 'Enter a name'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/station-name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; }
else { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 3000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Change password ───────────────────────────────────────────────────────────
document.getElementById('changePwBtn')?.addEventListener('click', async () => {
const cur = document.getElementById('pwCurrent')?.value;
const nw = document.getElementById('pwNew')?.value;
const msg = document.getElementById('pwMsg');
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: cur, newPassword: nw }) });
if (r.ok) { msg.textContent = 'Password changed'; msg.className = 'save-msg'; document.getElementById('pwCurrent').value = ''; document.getElementById('pwNew').value = ''; }
else { const e = await r.json(); msg.textContent = e.error || 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Alert thresholds ──────────────────────────────────────────────────────────
async function loadThresholds() {
try {
const r = await fetch('/api/subscriptions');
if (!r.ok) return;
const subs = await r.json();
const sub = subs?.find(s => s.thresholds);
if (!sub) return;
const t = sub.thresholds;
const set = (id, v) => { const el = document.getElementById(id); if (el && v) el.value = v; };
set('thrTempHigh', t.tempHigh);
set('thrTempLow', t.tempLow);
set('thrWind', t.wind);
set('thrRainRate', t.rainRate);
} catch {}
}
document.getElementById('saveThreshBtn')?.addEventListener('click', async () => {
const msg = document.getElementById('threshMsg');
const val = id => { const v = document.getElementById(id)?.value; return v ? parseFloat(v) : undefined; };
const thresholds = {};
const th = val('thrTempHigh'); if (th) thresholds.tempHigh = th;
const tl = val('thrTempLow'); if (tl) thresholds.tempLow = tl;
const wi = val('thrWind'); if (wi) thresholds.wind = wi;
const rr = val('thrRainRate'); if (rr) thresholds.rainRate = rr;
try {
const r = await fetch('/api/subscriptions/thresholds', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thresholds }) });
if (r.ok) { msg.textContent = 'Saved — applies to all your subscriptions'; msg.className = 'save-msg'; }
else { msg.textContent = 'Error saving'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Tile color coding ─────────────────────────────────────────────────────────
function tempColorClass(t) {
if (t == null) return '';
if (t >= 95) return 'stat-hot';
if (t >= 80) return 'stat-warm';
if (t <= 32) return 'stat-cold';
if (t <= 50) return 'stat-cool';
return '';
}
function uvColorClass(uv) {
if (uv == null || uv < 3) return '';
if (uv < 6) return 'stat-uv-mod';
if (uv < 8) return 'stat-uv-high';
return 'stat-uv-xhigh';
}
function applyClass(el, cls) {
if (!el) return;
el.classList.remove('stat-hot','stat-warm','stat-cool','stat-cold','stat-raining','stat-uv-mod','stat-uv-high','stat-uv-xhigh');
if (cls) el.classList.add(cls);
}
function colorTiles(d) {
// Hero temp color
const heroTemp = document.getElementById('sTempF');
if (heroTemp && d.tempf != null) {
const cls = tempColorClass(d.tempf);
const colorMap = { 'stat-hot': 'var(--accent)', 'stat-warm': '#c8a000', 'stat-cold': '#4a7cc7', 'stat-cool': 'var(--cool)', '': 'var(--ink)' };
heroTemp.style.color = colorMap[cls] || 'var(--ink)';
}
// UV tile
const uvTile = document.querySelector('[id="sUV"]')?.closest('.hero-stat-val')?.parentElement;
// (using the hero-grid cell)
const uvParent = document.getElementById('sUV')?.closest('div[class=""]');
// Rain rate
const rrTile = document.getElementById('statRainRateTile');
if (rrTile) applyClass(rrTile, d.rainratein > 0 ? 'stat-raining' : '');
const rTile = document.getElementById('statRainTile');
if (rTile) applyClass(rTile, d.rainratein > 0 ? 'stat-raining' : '');
// Wind tile
const wTile = document.getElementById('statWindTile');
if (wTile) applyClass(wTile, d.windspeedmph >= 30 ? 'stat-hot' : d.windspeedmph >= 20 ? 'stat-warm' : '');
}
// ── Sparklines ────────────────────────────────────────────────────────────────
function drawSparkline(canvasId, values, color) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth || 100, H = 22;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const valid = values.filter(v => v != null);
if (valid.length < 2) return;
const min = Math.min(...valid), max = Math.max(...valid);
const range = max - min || 1;
const pad = 2;
ctx.clearRect(0, 0, W, H);
// gradient fill
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, color + '55'); grad.addColorStop(1, color + '00');
ctx.beginPath();
const step = (W - pad * 2) / (values.length - 1);
let startX = pad, startY;
let drawing = false;
const pts = [];
values.forEach((v, i) => {
if (v == null) return;
const x = pad + i * step;
const y = H - pad - ((v - min) / range) * (H - pad * 3);
pts.push([x, y]);
});
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round';
ctx.stroke();
// fill
ctx.lineTo(pts[pts.length-1][0], H); ctx.lineTo(pts[0][0], H); ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
}
function updateSparklines(readings) {
const step = readings.length > 120 ? Math.ceil(readings.length / 120) : 1;
const pts = step > 1 ? readings.filter((_, i) => i % step === 0) : readings;
const c = chartColors();
drawSparkline('spHum', pts.map(r => r.humidity), c.cool);
drawSparkline('spPres', pts.map(r => r.baromrelin), c.accent);
drawSparkline('spSolar', pts.map(r => r.solarradiation), c.accent);
drawSparkline('spWind', pts.map(r => r.windspeedmph), c.cool);
}
// ── Today at a glance ─────────────────────────────────────────────────────────
function updateTodayBar(readings) {
const midnightToday = new Date(); midnightToday.setHours(0,0,0,0);
const today = readings.filter(r => r.dateutc >= midnightToday.getTime());
if (!today.length) return;
const temps = today.map(r => r.tempf).filter(v => v != null);
const gusts = today.map(r => r.windgustmph).filter(v => v != null);
const maxRain = Math.max(...today.map(r => r.dailyrainin || 0));
if (temps.length) {
document.getElementById('todayHigh').textContent = Math.max(...temps).toFixed(1);
document.getElementById('todayLow').textContent = Math.min(...temps).toFixed(1);
}
document.getElementById('todayRain').textContent = maxRain.toFixed(2);
if (gusts.length) document.getElementById('todayGust').textContent = Math.max(...gusts).toFixed(1);
document.getElementById('todayBar').style.display = '';
}
// ── NWS Alerts ────────────────────────────────────────────────────────────────
const SEV_CLASS = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
async function loadAlerts() {
const section = document.getElementById('alertsSection');
if (!section) return;
try {
const r = await fetch('/api/dashboard/alerts');
if (!r.ok) return;
const data = await r.json();
const features = data.features || [];
if (!features.length) { section.innerHTML = ''; section.style.marginBottom = '0'; return; }
section.style.marginBottom = '1rem';
section.innerHTML = features.map(f => {
const p = f.properties || {};
const sev = p.severity || 'unknown';
const cls = SEV_CLASS[sev] || 'sev-unknown';
const ends = p.ends ? new Date(p.ends).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
return `<div class="alert-card ${cls}">
<div class="alert-title">${p.event || 'Alert'}</div>
<div class="alert-meta">${p.areaDesc || ''}${ends ? ' · until ' + ends : ''}</div>
</div>`;
}).join('');
} catch {}
}
// ── Streak ─────────────────────────────────────────────────────────────────────
async function loadStreak() {
try {
const r = await fetch('/api/station/streak');
if (!r.ok) return;
const d = await r.json();
if (!d.type || !d.count) return;
const tile = document.getElementById('sStreakTile');
tile.style.display = '';
document.getElementById('sStreak').textContent = d.count;
document.getElementById('sStreakNote').textContent = d.count === 1 ? `${d.type} day` : `consecutive ${d.type} days`;
tile.style.borderColor = d.type === 'wet' ? 'var(--cool)' : 'var(--border)';
} catch {}
}
// ── Station location ──────────────────────────────────────────────────────────
async function loadLocation() {
try {
const r = await fetch('/api/dashboard/location');
if (!r.ok) return;
const loc = await r.json();
if (!loc) return;
const latEl = document.getElementById('inputLat');
const lonEl = document.getElementById('inputLon');
if (latEl) latEl.value = loc.lat;
if (lonEl) lonEl.value = loc.lon;
} catch {}
}
document.getElementById('saveLocBtn')?.addEventListener('click', async () => {
const msg = document.getElementById('locMsg');
const lat = parseFloat(document.getElementById('inputLat')?.value);
const lon = parseFloat(document.getElementById('inputLon')?.value);
if (!isFinite(lat) || !isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
msg.textContent = 'Enter valid lat/lon'; msg.className = 'save-msg err'; return;
}
try {
const r = await fetch('/api/dashboard/location', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat, lon }) });
if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; loadAlerts(); }
else { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 3000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Init all new features ─────────────────────────────────────────────────────
updateLatest();
updatePressureTrend();
updateExtStats();
updateTrends();
loadRecords();
loadHistory(currentRange);
checkKeyStatus();
loadStationName();
loadThresholds();
loadAlerts();
loadStreak();
loadLocation();
setInterval(updatePressureTrend, 120000);
setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000);
</script>
</body>
</html>