Files
weather/app/public/dashboard.html
T
Nate EmmertandClaude Sonnet 5.5 1830fb2e2b Merge /conditions into the app as a Station tab
- New Station tab (top tabs, bottom nav, swipe order) carrying the old /conditions look:
  radar-ring hero, condition pill, wind compass, atmosphere/precip/solar cards, indoor
- Scoped sl-* styles and station-live.js; units follow the app's metric/imperial setting
- Empty/error states when no station is connected
- /conditions now 301-redirects to /?tab=station; removed conditions.html,
  /api/conditions/public and the old My Station drawer
- /api/ambient-config also returns the station name
- Dashboard and settings links updated; SW cache v8; manifest shortcut for Station
- Drops an indoor-temp block in applyStationOverlay that referenced an out-of-scope helper

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:45:47 -04:00

1601 lines
78 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="#f5f2ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#191510" 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: #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; }
/* ── iPhone / touch ── */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
body { overscroll-behavior-y: none; 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="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 (['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: {
zoom: {
zoom: { wheel: { enabled: true }, 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);
}
// ── 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">Pressure Tendency · 3-hour change (inHg)</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 3 hours earlier (what forecasters watch)
if (hasPres) {
const tend = pts.map(r => {
const t = r.dateutc - 3 * 3600000;
let lo = 0, hi = pts.length - 1;
if (t < pts[0].dateutc) return null;
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('3h 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], lbls, 'inHg / 3h');
}
// 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'].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 / scroll to zoom · double-tap 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>';
if (d.wettestMonth || d.wettestYear) {
html += '<div class="rec-h">Wettest periods</div><div class="records-grid">'
+ (d.wettestMonth ? ri('Wettest Month', d.wettestMonth.rain.toFixed(2) + '"', dayDate(d.wettestMonth.month + '-15')) : '')
+ (d.wettestYear ? ri('Wettest Year', d.wettestYear.rain.toFixed(2) + '"', dayDate(d.wettestYear.year + '-07-01')) : '')
+ '</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>