Files
weather/app/public/dashboard.html
T
Nate EmmertandClaude Sonnet 5.5 528dec753d
Publish Container / publish (push) Successful in 2m50s
Make dashboard alerts short with click-to-expand full details
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 11:45:50 -04:00

1495 lines
71 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 (collapsible sections) ── */
.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); }
/* 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); }
/* 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); }
.alert-card summary { cursor: pointer; list-style: none; }
.alert-card summary::-webkit-details-marker { display: none; }
.alert-summary { margin-top: 0.2rem; line-height: 1.35; }
.alert-hint { font-size: 0.72rem; color: var(--ink-light); margin-top: 0.2rem; }
.alert-card details[open] .alert-hint { display: none; }
.alert-full { margin-top: 0.5rem; white-space: pre-wrap; line-height: 1.45; font-size: 0.82rem; }
.alert-full h4 { margin: 0.6rem 0 0.15rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); }
.ah-row { cursor: pointer; }
.ah-row .ah-body { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.ah-row.open .ah-body { display: block; white-space: pre-wrap; }
/* 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; }
.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/settings" class="logout">Settings</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>
</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);
// ── 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;
// ── 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 });
}
// ── 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 esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
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();
if (data.noLocation) {
section.style.marginBottom = '1rem';
section.innerHTML = '<div class="alert-card sev-unknown" style="font-size:0.85rem">⚠ No location set — <a href="/dashboard/settings" style="color:var(--cool)">add your coordinates in Settings</a> to see NWS alerts.</div>';
return;
}
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' }) : '';
const desc = (p.description || '').trim();
const firstSentence = (p.headline && !desc) ? p.headline : (desc.replace(/\s+/g, ' ').match(/^.*?[.!?](\s|$)/) || [desc.replace(/\s+/g, ' ')])[0].trim();
const short = firstSentence.length > 140 ? firstSentence.slice(0, 137) + '…' : firstSentence;
const instr = (p.instruction || '').trim();
return `<div class="alert-card ${cls}"><details>
<summary>
<div class="alert-title">${esc(p.event || 'Alert')}</div>
<div class="alert-meta">${esc(p.areaDesc || '')}${ends ? ' · until ' + ends : ''}</div>
${short ? `<div class="alert-summary">${esc(short)}</div>` : ''}
<div class="alert-hint">Click for full details ▾</div>
</summary>
<div class="alert-full">${esc(desc || p.headline || 'No further details.')}${instr ? `<h4>What to do</h4>${esc(instr)}` : ''}</div>
</details></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 {}
}
// ── 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 full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail;
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
<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">${esc(detail)}</div></td>
</tr>`;
}).join('')}</tbody>
</table></div>`
}`;
body.querySelectorAll('.ah-row').forEach(row => {
row.addEventListener('click', () => {
const el = row.querySelector('.ah-body');
const open = row.classList.toggle('open');
el.textContent = open ? row.dataset.full : el.dataset.short;
});
const el = row.querySelector('.ah-body');
el.dataset.short = el.textContent;
});
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);
loadAlerts();
loadStreak();
setInterval(updatePressureTrend, 120000);
setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000);
</script>
</body>
</html>