Files
weather/app/public/dashboard.html
T
Nate EmmertandClaude Sonnet 4.6 97dfae86ab
Publish Container / publish (push) Successful in 2m33s
Add alert history dashboard endpoint and UI panel
GET /api/dashboard/alert-history?days=N reads daily .jsonl files from
data/alerts/ and returns entries newest-first. Dashboard gets a lazy-loaded
collapsible panel with a table showing time, type (NWS/threshold badge),
event name, and details, with 7d/30d/90d range selector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 08:01:41 -04:00

1834 lines
87 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: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
.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(--ink-light);
text-decoration: none;
font-size: 0.78rem;
font-weight: 600;
padding: 0.32rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, white 5%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: background .18s cubic-bezier(.2,0,0,1),
border-color .18s cubic-bezier(.2,0,0,1),
color .18s cubic-bezier(.2,0,0,1),
box-shadow .18s cubic-bezier(.2,0,0,1),
transform .1s cubic-bezier(.2,0,0,1);
}
.logout:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.logout:active { transform: scale(0.95); box-shadow: none; }
/* ── 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;
transition: transform .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1);
}
.stat:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.14); }
.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: color-mix(in srgb, white 5%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: var(--ink);
font-family: var(--mono);
font-size: 0.82rem;
cursor: pointer;
transition: background .18s cubic-bezier(.2,0,0,1),
border-color .18s cubic-bezier(.2,0,0,1),
color .18s cubic-bezier(.2,0,0,1),
box-shadow .18s cubic-bezier(.2,0,0,1),
transform .1s cubic-bezier(.2,0,0,1);
}
.range-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.range-btn:hover {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.range-btn:active { transform: scale(0.95); box-shadow: none; }
.refresh-btn {
margin-left: auto;
padding: 0.3rem 0.75rem;
border: 1px solid var(--border);
border-radius: 20px;
background: color-mix(in srgb, white 5%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: var(--ink-light);
font-family: var(--mono);
font-size: 0.82rem;
cursor: pointer;
transition: border-color .18s cubic-bezier(.2,0,0,1),
color .18s cubic-bezier(.2,0,0,1),
box-shadow .18s cubic-bezier(.2,0,0,1),
transform .1s cubic-bezier(.2,0,0,1);
}
.refresh-btn:hover {
border-color: var(--cool);
color: var(--cool);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cool) 20%, transparent);
}
.refresh-btn:active { transform: scale(0.95); box-shadow: none; }
/* ── 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;
transition: opacity .18s cubic-bezier(.2,0,0,1),
box-shadow .18s cubic-bezier(.2,0,0,1),
transform .1s cubic-bezier(.2,0,0,1);
}
.settings-body button:hover {
opacity: 0.88;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}
.settings-body button:active { transform: scale(0.95); box-shadow: none; }
.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; }
/* Alert history table */
.alert-history-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; flex-wrap: wrap; }
.alert-history-table-wrap { overflow-x: auto; }
.alert-history-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
.alert-history-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); white-space: nowrap; }
.alert-history-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.alert-history-table tr:last-child td { border-bottom: none; }
.alert-history-table tr:hover td { background: var(--paper-darker); }
.ah-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.ah-badge.nws { background: rgba(194,90,42,0.15); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
.ah-badge.threshold { background: rgba(42,107,138,0.15); color: var(--cool); border: 1px solid color-mix(in srgb, var(--cool) 40%, transparent); }
.ah-body { color: var(--ink-light); font-size: 0.78rem; margin-top: 0.1rem; line-height: 1.4; }
.ah-empty { padding: 1.5rem; text-align: center; color: var(--ink-light); font-size: 0.85rem; }
/* 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 {
border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
border: 1px solid transparent;
background:
linear-gradient(var(--paper-dark), var(--paper-dark)) padding-box,
linear-gradient(140deg, color-mix(in srgb, var(--accent) 40%, var(--border)), var(--border) 50%, color-mix(in srgb, var(--cool) 38%, var(--border))) border-box;
}
.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: transform .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1);
}
.chart-card:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(0,0,0,0.12); }
/* ── Today bar hover ── */
.today-bar { transition: transform .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1); }
.today-bar:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
/* ── Live dot ── */
.live-dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: #3cba6e; margin-right: 5px;
vertical-align: middle; position: relative; bottom: 1px;
animation: live-pulse 2.4s ease-in-out infinite;
}
@keyframes live-pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:.35; transform:scale(.65); }
}
/* ── Skeleton shimmer ── */
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.stat.loading .stat-val {
background: linear-gradient(90deg, var(--border) 25%, var(--paper-darker) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite linear;
color: transparent !important;
border-radius: 4px; min-width: 48px; display: inline-block;
}
.stat.loading .stat-unit { opacity: 0; }
/* ── Value flash on data update ── */
@keyframes val-flash {
0% { opacity: 0.15; transform: translateY(4px); }
100% { opacity: 1; transform: translateY(0); }
}
.val-flash { animation: val-flash .4s cubic-bezier(.2,0,0,1); }
/* ── Record best highlight ── */
.record-item.record-best {
border-color: rgba(255,195,0,0.65);
box-shadow: 0 0 0 2px rgba(255,195,0,0.12);
}
.record-best .record-label::after { content: ' ★'; color: #ffc300; }
</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="/conditions" class="logout">Conditions</a>
<a href="/" class="logout">← Weather</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>
<!-- Alert History -->
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
<summary>Alert History</summary>
<div class="settings-body" id="alertHistoryBody">
<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 ctx = canvas.getContext('2d');
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();
const sliceAngle = (Math.PI * 2) / 16;
function drawFrame(progress) {
ctx.clearRect(0, 0, W, H);
// grid circles
for (let i = 1; i <= 4; i++) {
ctx.beginPath(); ctx.arc(cx, cy, R * i / 4, 0, Math.PI * 2);
ctx.strokeStyle = colBorder + '66'; ctx.lineWidth = 0.5; ctx.stroke();
}
// cardinal lines + labels
['N','E','S','W'].forEach((dir, i) => {
const a = i * Math.PI / 2 - Math.PI / 2;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
ctx.strokeStyle = colBorder + '88'; ctx.lineWidth = 0.5; ctx.stroke();
ctx.fillStyle = colInkLight; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(dir, cx + Math.cos(a) * (R + 12), cy + Math.sin(a) * (R + 12));
});
// petals
for (let i = 0; i < 16; i++) {
const a = i * sliceAngle - Math.PI / 2 - sliceAngle / 2;
const len = (buckets[i] / max) * R * progress;
if (len < 1) continue;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, len, a, a + sliceAngle); ctx.closePath();
ctx.fillStyle = colAccent + 'cc'; ctx.fill();
ctx.strokeStyle = colAccent; ctx.lineWidth = 0.5; ctx.stroke();
}
// center dot
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fillStyle = colInk; ctx.fill();
}
// Animate petals growing in over 650ms
const dur = 650, t0 = performance.now();
(function animate(now) {
const p = Math.min((now - t0) / dur, 1);
const ease = 1 - Math.pow(1 - p, 3);
drawFrame(ease);
if (p < 1) requestAnimationFrame(animate);
})(t0);
}
// ── 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) return;
const text = val != null ? (typeof val === 'number' ? val.toFixed(digits) : String(val)) : '—';
// Preserve child elements (trend spans) by only updating the leading text node
const tn = el.firstChild;
if (tn && tn.nodeType === 3) tn.textContent = text;
else el.textContent = text;
};
// Count-up animation for hero temperature
const animateHeroTemp = (val, digits = 1) => {
const el = document.getElementById('sTempF');
if (!el || val == null || typeof val !== 'number') { set('sTempF', val, digits); return; }
const tn = el.firstChild && el.firstChild.nodeType === 3 ? el.firstChild : null;
const from = tn ? (parseFloat(tn.textContent) || val) : val;
const diff = val - from;
if (Math.abs(diff) < 0.05) { if (tn) tn.textContent = val.toFixed(digits); return; }
const dur = 700, t0 = performance.now();
(function step(now) {
const p = Math.min((now - t0) / dur, 1);
const ease = 1 - Math.pow(1 - p, 3);
const text = (from + diff * ease).toFixed(digits);
if (tn) tn.textContent = text; else el.textContent = text;
if (p < 1) requestAnimationFrame(step);
})(t0);
};
animateHeroTemp(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);
// Remove skeleton shimmer now that data has arrived
document.querySelectorAll('.stat.loading').forEach(s => s.classList.remove('loading'));
// Flash stat values on update
requestAnimationFrame(() => {
document.querySelectorAll('.stat-val, .hero-temp, .hero-stat-val').forEach(el => {
el.classList.remove('val-flash');
requestAnimationFrame(() => el.classList.add('val-flash'));
});
});
const name = document.getElementById('stationName');
if (name && d.PASSKEY) name.textContent = '';
const meta = document.getElementById('headerMeta');
if (meta && d.dateutc) {
meta.innerHTML = '<span class="live-dot"></span>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)}` : '';
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
const ri = (label, val, date) => {
const recent = date && new Date(date).getTime() > cutoff;
return `<div class="record-item${recent ? ' record-best' : ''}"><div class="record-label">${label}</div><div class="record-val">${val}</div><div class="record-date">${fmtDate(date)}</div></div>`;
};
body.innerHTML = `
<p style="font-size:0.8rem;color:var(--ink-light);margin-bottom:0.75rem">${since}</p>
<div class="records-grid">
${ri('Hottest', d.highTemp != null ? d.highTemp.toFixed(1) + '°F' : '—', d.highTempDate)}
${ri('Coldest', d.lowTemp != null ? d.lowTemp.toFixed(1) + '°F' : '—', d.lowTempDate)}
${ri('Highest Gust', d.highGust != null ? d.highGust.toFixed(1) + ' mph' : '—', d.highGustDate)}
${ri('Highest Rain Rate', d.highRainRate != null ? d.highRainRate.toFixed(2) + ' in/hr' : '—', d.highRainRateDate)}
${ri('Wettest Day', d.highDailyRain != null ? d.highDailyRain.toFixed(2) + '"' : '—', d.highDailyRainDate)}
${ri('Highest UV', d.highUV != null ? d.highUV.toFixed(0) : '—', d.highUVDate)}
${ri('Peak Solar', d.highSolar != null ? d.highSolar.toFixed(0) + ' W/m²' : '—', d.highSolarDate)}
${ri('Highest Pressure', d.highPressure != null ? d.highPressure.toFixed(2) + ' inHg' : '—', d.highPressureDate)}
${ri('Lowest Pressure', d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—', d.lowPressureDate)}
${ri('Highest Humidity', d.highHumidity != null ? d.highHumidity + '%' : '—', d.highHumidityDate)}
${ri('Lowest Humidity', d.lowHumidity != null ? d.lowHumidity + '%' : '—', d.lowHumidityDate)}
</div>`;
} 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'; }
});
// ── Alert History ─────────────────────────────────────────────────────────────
const TAG_LABELS = {
'nws': 'NWS Alert',
'wind-threshold': 'Wind Gust',
'temp-threshold': 'Low Temp',
'aqi-threshold': 'Air Quality',
'rain-threshold': 'Rain Rate',
'temp-high': 'High Temp',
'temp-low': 'Station Low',
'freeze': 'Freeze',
'wind-station': 'Station Wind',
'station-offline': 'Station Offline',
};
let alertHistoryDays = 30;
async function loadAlertHistory(days) {
alertHistoryDays = days;
const body = document.getElementById('alertHistoryBody');
body.innerHTML = '<div class="status">Loading…</div>';
try {
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
if (!r.ok) throw new Error();
const items = await r.json();
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
body.innerHTML = `
<div class="alert-history-bar">
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
${[7,30,90].map(d => `<button class="range-btn${d === days ? ' active' : ''}" data-ahdays="${d}">${d}d</button>`).join('')}
<span style="margin-left:auto;font-size:0.78rem;color:var(--ink-light)">${items.length} event${items.length !== 1 ? 's' : ''}</span>
</div>
${items.length === 0
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
<tbody>${items.map(it => {
const isNws = it.type === 'nws';
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
const detail = isNws ? (it.headline || '') : (it.body || '');
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `<tr>
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
<td>${badge}</td>
<td style="font-weight:600">${label}</td>
<td><div class="ah-body">${detail}</div></td>
</tr>`;
}).join('')}</tbody>
</table></div>`
}`;
body.querySelectorAll('[data-ahdays]').forEach(btn => {
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
});
} catch {
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
}
}
const alertHistoryPanel = document.getElementById('alertHistoryPanel');
if (alertHistoryPanel) {
alertHistoryPanel.addEventListener('toggle', () => {
if (!alertHistoryPanel.open) return;
const body = document.getElementById('alertHistoryBody');
if (body.querySelector('table, .ah-empty')) return;
loadAlertHistory(alertHistoryDays);
}, { once: false });
}
// ── Scroll handler: add blur shadow to sticky header ─────────────────────────
window.addEventListener('scroll', () => {
document.querySelector('.header').classList.toggle('scrolled', window.scrollY > 5);
}, { passive: true });
// ── Skeleton: shimmer stat tiles until first data arrives ─────────────────────
document.querySelectorAll('.stat').forEach(s => s.classList.add('loading'));
// ── 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>