Files
weather/app/public/dashboard.html
T
Nate EmmertandClaude Sonnet 5.5 72d813bd61
Publish Container / publish (push) Successful in 3m55s
Records: month and year records with the period as the headline
- Wettest/driest/hottest/coldest/windiest month and wettest/hottest/coldest/windiest year,
  each naming the period (e.g. 'September 2026') and flagging partial periods
- Average-based records prefer complete periods; counterparts say what they're waiting for
- 'Records by calendar month (any year)' table with the date/year of each record

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:21:55 -04:00

1693 lines
86 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, viewport-fit=cover">
<meta name="theme-color" content="#edf2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#070c14" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Station">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<title>Station Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/dashboard-ui.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #0d1b2a; --ink-mid: #2b3f55; --ink-light: #5a7089;
--paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
--border: #ccd6e2; --accent: #c25a2a; --cool: #2a6b8a;
--ui: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #dce6f0; --ink-mid: #b7c6d6; --ink-light: #8aa2b8;
--paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29;
--border: #22405c; --accent: #fb923c; --cool: #38bdf8; --ui: #60a5fa;
}
}
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; }
/* ── iPhone / touch ── */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
html { overscroll-behavior-y: none; }
body { min-height: 100vh; min-height: 100dvh; }
.header { padding-top: calc(1rem + env(safe-area-inset-top)); padding-left: calc(1.5rem + env(safe-area-inset-left)); padding-right: calc(1.5rem + env(safe-area-inset-right)); }
.page { padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); padding-bottom: calc(4rem + env(safe-area-inset-bottom)); }
/* Let the page scroll when a finger starts on a chart; taps/long-press still show tooltips */
.chart-wrap canvas { touch-action: pan-y; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
button, .range-btn, .refresh-btn, .logout, summary { touch-action: manipulation; }
input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
@media (hover: none) {
.chart-card:hover, .today-bar:hover { transform: none; box-shadow: none; }
}
@media (pointer: coarse) {
.range-btn, .refresh-btn, .logout { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding-left: 0.9rem; padding-right: 0.9rem; }
.settings-panel summary { min-height: 48px; }
}
@media (max-width: 640px) {
.header { flex-wrap: wrap; gap: 0.6rem; padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); }
.header-right { width: 100%; flex-wrap: nowrap; overflow-x: auto; gap: 0.5rem; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 2px; }
.header-right::-webkit-scrollbar { display: none; }
.header-right > * { flex-shrink: 0; }
.range-bar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; }
.range-bar::-webkit-scrollbar { display: none; }
.range-bar > * { flex-shrink: 0; }
.today-bar { flex-wrap: wrap; row-gap: 0.4rem; }
.chart-card { padding: 0.8rem 0.8rem 1rem; }
.chart-wrap { height: 220px; }
.settings-body { padding: 0.9rem; }
.records-grid { grid-template-columns: repeat(2, 1fr); }
.record-val { font-size: 1.05rem; }
}
/* Records tables */
.rec-h { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin: 1.1rem 0 0.5rem; }
.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.rec-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
.rec-table th, .rec-table td { text-align: right; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.rec-table th:first-child, .rec-table td:first-child { text-align: left; }
.rec-table th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); font-weight: 600; }
.rec-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.1rem; }
.rec-actions a { display: inline-flex; align-items: center; min-height: 40px; padding: 0 0.9rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.8rem; font-weight: 600; background: var(--paper); }
</style>
</head>
<body>
<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="/?tab=station" class="logout">Station</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>
<nav class="tabbar" id="tabbar" aria-label="Dashboard sections">
<button class="tab active" data-tab="now"><span class="tab-ico">◉</span><span>Now</span></button>
<button class="tab" data-tab="charts"><span class="tab-ico">〰</span><span>Charts</span></button>
<button class="tab" data-tab="rain"><span class="tab-ico">☂</span><span>Rain</span></button>
<button class="tab" data-tab="records"><span class="tab-ico">★</span><span>Records</span></button>
<button class="tab" data-tab="insights"><span class="tab-ico">✦</span><span>Insights</span></button>
</nav>
<div class="page">
<div class="offline-banner" id="offlineBanner"></div>
<div id="alertsSection" style="margin-bottom:0"></div>
<div class="tab-pane active" data-pane="now">
<div id="highlights" class="highlights"></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>
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div></div>
</div><!-- /now -->
<div class="tab-pane" data-pane="charts">
<!-- 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="range-btn" data-range="all" title="Everything recorded so far">All</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>
</div><!-- /charts -->
<div class="tab-pane" data-pane="rain" id="rainTab"><div class="status">Loading rain…</div></div>
<div class="tab-pane" data-pane="records">
<!-- 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>
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
</div><!-- /records -->
<div class="tab-pane" data-pane="insights" id="insightsTab">
<div id="insightsBody"><div class="status">Loading insights…</div></div>
<!-- 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><!-- /insights -->
</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 (range === '2d') return d.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true });
if (['1y','2y','3y','all'].includes(range)) {
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
}
if (['7d','30d','90d'].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: {
zoom: {
zoom: { wheel: { enabled: true, modifierKey: 'ctrl' }, pinch: { enabled: true }, mode: 'x', onZoomComplete: ({ chart }) => markZoomed(chart) },
pan: { enabled: true, mode: 'x', threshold: 10, onPanComplete: ({ chart }) => markZoomed(chart) },
},
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 });
ctx.ondblclick = () => { charts[canvasId].resetZoom(); markZoomed(charts[canvasId], false); };
return charts[canvasId];
}
function markZoomed(chart, on = true) {
const card = chart.canvas.closest('.chart-card');
if (!card) return;
let btn = card.querySelector('.chart-reset');
if (!btn) {
btn = document.createElement('button');
btn.className = 'chart-reset'; btn.textContent = 'Reset zoom';
btn.addEventListener('click', () => { chart.resetZoom(); markZoomed(chart, false); });
card.querySelector('.chart-title')?.appendChild(btn);
}
btn.style.display = on ? '' : 'none';
}
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);
}
// ── Pressure tendency (own range + change window) ────────────────────────────
let presTendRange = 'follow', presTendWin = 3;
try { presTendRange = localStorage.getItem('ptRange') || 'follow'; presTendWin = parseInt(localStorage.getItem('ptWin')) || 3; } catch {}
const presTendCache = {};
let presTendLast = { pts: null, range: null };
async function drawPresTend(pagePts, pageRange) {
const canvas = document.getElementById('cPresTend');
if (!canvas) return;
let pts = pagePts, range = pageRange;
if (presTendRange !== 'follow') {
range = presTendRange;
const hit = presTendCache[range];
if (hit && Date.now() - hit.at < 120000) pts = hit.data;
else {
try {
const bucket = ['7d', '30d', 'all'].includes(range) ? '&points=720' : '';
const r = await fetch(`/api/station/history?range=${range}${bucket}`);
pts = r.ok ? await r.json() : [];
} catch { pts = []; }
presTendCache[range] = { at: Date.now(), data: pts };
}
if (document.getElementById('cPresTend') !== canvas) return; // charts were re-rendered meanwhile
}
if (pts.length > 900) { const step = Math.ceil(pts.length / 720); pts = pts.filter((_, i) => i % step === 0); }
// Bucketed (long) ranges can't resolve changes shorter than one bucket: widen the window to fit
const bucketMs = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 0;
const effHours = Math.max(presTendWin, Math.ceil(bucketMs / 3600000));
const winMs = effHours * 3600000;
const c = chartColors();
const tend = pts.map(r => {
const t = r.dateutc - winMs;
if (!pts.length || t < pts[0].dateutc) return null;
let lo = 0, hi = pts.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < t) lo = m + 1; else hi = m; }
const base = pts[lo].baromrelin;
return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null;
});
const ds = barDataset(`${effHours}h change`, tend, c.cool);
ds.backgroundColor = tend.map(v => v == null ? 'transparent' : (v >= 0 ? c.cool + 'bb' : c.accent + 'bb'));
ds.borderWidth = 0;
makeChart('cPresTend', 'bar', [ds], pts.map(r => fmtTime(r.dateutc, range)), `inHg / ${effHours}h`);
const title = document.getElementById('ptTitle');
if (title) title.textContent = `Pressure Tendency · ${effHours}-hour change (inHg)${effHours !== presTendWin ? ' — widened to fit this range' : ''}`;
if (!tend.some(v => v != null) && title) title.textContent += ' — not enough data for this window';
}
function initPresTend(pagePts, pageRange) {
presTendLast = { pts: pagePts, range: pageRange };
const selRange = document.getElementById('ptRange'), selWin = document.getElementById('ptWin');
if (selRange) {
selRange.value = presTendRange;
selRange.addEventListener('change', () => { presTendRange = selRange.value; try { localStorage.setItem('ptRange', presTendRange); } catch {} drawPresTend(presTendLast.pts, presTendLast.range); });
}
if (selWin) {
selWin.value = String(presTendWin);
selWin.addEventListener('change', () => { presTendWin = parseInt(selWin.value); try { localStorage.setItem('ptWin', presTendWin); } catch {} drawPresTend(presTendLast.pts, presTendLast.range); });
}
drawPresTend(pagePts, pageRange);
}
// ── 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>
<div class="chart-card wide" id="cardPresTend">
<div class="chart-title"><span id="ptTitle">Pressure Tendency (inHg)</span>
<span class="chart-ctl">
<label>Range <select id="ptRange" aria-label="Pressure tendency range">
<option value="follow">Same as page</option><option value="6h">6 hours</option><option value="24h">24 hours</option>
<option value="2d">2 days</option><option value="7d">7 days</option><option value="30d">30 days</option><option value="all">All time</option></select></label>
<label>Change over <select id="ptWin" aria-label="Pressure change window">
<option value="1">1 hour</option><option value="3">3 hours</option><option value="6">6 hours</option>
<option value="12">12 hours</option><option value="24">24 hours</option></select></label>
</span></div>
<div class="chart-wrap" style="height:140px"><canvas id="cPresTend"></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>
${readings.some(r => r.tempinf != null) ? `<div class="chart-card wide">
<div class="chart-title">Indoor vs Outdoor</div>
<div class="chart-wrap"><canvas id="cIndoor"></canvas></div>
</div>` : ''}
</div>
`;
const tempDatasets = [];
if (pts.some(r => r.tempMax != null)) {
// Bucketed long ranges: show the true min/max envelope behind the average
tempDatasets.push({ label: 'Max', data: field(pts, 'tempMax'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: '+1', tension: 0.3 });
tempDatasets.push({ label: 'Min', data: field(pts, 'tempMin'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: false, tension: 0.3 });
}
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
tempDatasets.push(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', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
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 } } });
const solarSets = [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)];
if (typeof clearSkyGHI === 'function' && ['1h', '6h', '24h', '2d', '7d'].includes(range) && window.STATION_LOC) {
const cs = pts.map(r => clearSkyGHI(r.dateutc));
if (cs.some(v => v != null)) solarSets.push(lineDataset('Clear-sky potential', cs, c.cool, true));
}
makeChart('cSolar', 'line', solarSets, lbls, 'W/m²', { scales: { y: { min: 0 } } });
// Pressure tendency: change vs N hours earlier (what forecasters watch); has its own range + window
if (hasPres) initPresTend(pts, range);
// Indoor vs outdoor
if (pts.some(r => r.tempinf != null)) {
makeChart('cIndoor', 'line',
[lineDataset('Outdoor °F', field(pts, 'tempf'), c.accent),
lineDataset('Indoor °F', field(pts, 'tempinf'), c.cool),
lineDataset('Indoor humidity %', field(pts, 'humidityin'), c.cool, true)],
lbls, null);
}
// 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 pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=720' : '';
const r = await fetch(`/api/station/history?range=${range}${pts}`);
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}${data.some(d => d.n) ? ' time buckets' : ' readings'} · pinch or Ctrl+scroll to zoom · double-click to reset · 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' });
}
function renderRecordPeriods(d, ri) {
const dayDate = (day) => day ? Date.parse(day + 'T12:00:00Z') : null;
const n = d.notable || {};
const nv = (x, f) => x ? f(x.value) : '—';
let html = '<div class="rec-h">Notable days</div><div class="records-grid">' + [
['Hottest Day', nv(n.hottestDay, v => v.toFixed(1) + '°F'), n.hottestDay],
['Coldest Night', nv(n.coldestDay, v => v.toFixed(1) + '°F'), n.coldestDay],
['Coolest Daytime High', nv(n.coolestHigh, v => v.toFixed(1) + '°F'), n.coolestHigh],
['Warmest Night', nv(n.warmestLow, v => v.toFixed(1) + '°F'), n.warmestLow],
['Windiest Day', nv(n.windiestDay, v => v.toFixed(1) + ' mph'), n.windiestDay],
['Wettest Day', nv(n.wettestDay, v => v.toFixed(2) + '"'), n.wettestDay],
].map(([l, v, o]) => ri(l, v, o ? dayDate(o.day) : null)).join('') + '</div>';
// Months & years: the period itself (e.g. "October 2026") is the headline of each card
const now = new Date();
const curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'), curYear = String(now.getFullYear());
const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
const rp = (label, rec, fmtVal, isMonth, waitMsg) => {
if (!rec) return `<div class="record-item"><div class="record-label">${label}</div><div class="record-val">—</div><div class="record-period">${waitMsg || 'Not enough data yet'}</div></div>`;
const current = rec.key === (isMonth ? curMonth : curYear);
return `<div class="record-item${current ? ' record-best' : ''}"><div class="record-label">${label}</div><div class="record-val">${fmtVal(rec.value)}</div>
<div class="record-period">${isMonth ? longMonth(rec.key) : rec.key}</div><div class="record-date">${rec.days} day${rec.days === 1 ? '' : 's'} recorded${rec.partial ? ' · partial' : ''}</div></div>`;
};
const P = JSON.parse(JSON.stringify(d.periods || { months: {}, years: {} }));
// An extreme and its opposite landing on the same period just means there's nothing to compare yet
const same = (a, b) => a && b && a.key === b.key;
const waitM = 'Needs another full month', waitY = 'Needs another year of data';
let wM = {}, wY = {};
if (same(P.months.hottest, P.months.coldest)) { P.months.coldest = null; wM.coldest = waitM; }
if (same(P.months.wettest, P.months.driest)) { P.months.driest = null; wM.driest = waitM; }
if (same(P.years.hottest, P.years.coldest)) { P.years.coldest = null; wY.coldest = waitY; }
const deg = (v) => v.toFixed(1) + '°F', inch = (v) => v.toFixed(2) + '"', mph = (v) => v.toFixed(0) + ' mph';
html += '<div class="rec-h">Months <span class="rec-sub">(avg temp = mean of all readings; partial months are flagged)</span></div><div class="records-grid">'
+ rp('Wettest Month', P.months.wettest, inch, true) + rp('Driest Month', P.months.driest, inch, true, wM.driest)
+ rp('Hottest Month', P.months.hottest, deg, true) + rp('Coldest Month', P.months.coldest, deg, true, wM.coldest)
+ rp('Windiest Month', P.months.windiest, mph, true) + '</div>';
html += '<div class="rec-h">Years</div><div class="records-grid">'
+ rp('Wettest Year', P.years.wettest, inch, false) + rp('Hottest Year', P.years.hottest, deg, false)
+ rp('Coldest Year', P.years.coldest, deg, false, wY.coldest) + rp('Windiest Year', P.years.windiest, mph, false) + '</div>';
// Records for each calendar month, whichever year they happened in
if (d.calendarMonths && d.calendarMonths.some(c => c.years)) {
const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
const cell = (x, fmtV) => x ? `<b>${fmtV(x.value)}</b><span class="rec-yr">${x.day ? new Date(x.day + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : ''}</span>` : '<span class="rec-yr">—</span>';
const rows = d.calendarMonths.map((c, i) => `<tr${c.years ? '' : ' class="empty"'}><td><b>${mn(i)}</b><span class="rec-yr">${c.years ? c.years + ' year' + (c.years > 1 ? 's' : '') : 'no data'}</span></td>
<td>${cell(c.hi, deg)}</td><td>${cell(c.lo, deg)}</td><td>${cell(c.wettestDay, inch)}</td>
<td>${c.wettestTotal ? `<b>${inch(c.wettestTotal.value)}</b><span class="rec-yr">${longMonth(c.wettestTotal.month)}</span>` : '<span class="rec-yr">—</span>'}</td><td>${cell(c.gust, mph)}</td></tr>`).join('');
html += `<div class="rec-h">Records by calendar month <span class="rec-sub">(any year)</span></div><div class="rec-scroll"><table class="rec-table rec-cal"><tr><th>Month</th><th>Record high</th><th>Record low</th><th>Wettest day</th><th>Wettest month</th><th>Peak gust</th></tr>${rows}</table></div>`;
}
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
const row = (label, a) => `<tr><td>${label}</td><td>${a.hi != null ? a.hi.toFixed(1) : '—'}</td><td>${a.lo != null ? a.lo.toFixed(1) : '—'}</td><td>${a.gust != null ? a.gust.toFixed(0) : '—'}</td><td>${a.rain.toFixed(2)}</td><td>${a.days}</td></tr>`;
const head = '<tr><th></th><th>High °F</th><th>Low °F</th><th>Gust</th><th>Rain "</th><th>Days</th></tr>';
if (d.years && d.years.length) {
html += `<div class="rec-h">By year</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.years].reverse().map(y => row(y.year, y)).join('')}</table></div>`;
}
if (d.months && d.months.length) {
html += `<div class="rec-h">By month</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.months].reverse().map(m => row(mname(m.month), m)).join('')}</table></div>`;
}
return html;
}
async function loadRecords() {
const panel = document.getElementById('recordsPanel');
if (!panel) return;
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 over ${d.daysRecorded} days since ${fmtDate(d.firstReading)}` : '';
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
const ri = (label, val, date) => {
const recent = date && new Date(date).getTime() > cutoff;
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>
${renderRecordPeriods(d, ri)}
<div class="rec-actions">
<a href="/api/station/backup.jsonl" download>⬇ Download all readings</a>
<a href="/api/station/records-backup.json" download>⬇ Download records</a>
</div>`;
} catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; }
}, { once: false });
}
// ── 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 });
}
// ── 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>
<script src="/dashboard-ui.js"></script>
</body>
</html>