- Restore tempinf/humidityin tiles from PWS data on index.html (removed with thermostat cleanup but still available from Ambient station) - Dashboard: replace two flat secondary-stat grids with labeled section cards — Wind, Precipitation, Climate, Indoor — for clearer visual hierarchy and category separation - Indoor section card includes Ecobee portal link; appears only when station reports indoor sensor data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1562 lines
76 KiB
HTML
1562 lines
76 KiB
HTML
<!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>° 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 <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');
|
||
const W = canvas.width, H = canvas.height;
|
||
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);
|
||
document.getElementById('sEventRainTile').style.display = '';
|
||
}
|
||
if (d.totalrainin != null) {
|
||
set('sTotalRain', d.totalrainin, 2);
|
||
document.getElementById('sTotalRainTile').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 && d.winddir != null) windDirEl.textContent = 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>
|