Compare commits

..
9 Commits
Author SHA1 Message Date
Nate EmmertandClaude Sonnet 5.5 2fb467d2e5 Weather proxy: fall back to the classic request if 15-minute data is rejected
Publish Container / publish (push) Successful in 3m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:56:31 -04:00
Nate EmmertandClaude Sonnet 5.5 fd61e35b56 Redesign main app: slim top bar, dynamic sky hero, nowcast, scrub chart, unified palette
- One slate/blue palette across the app, dashboard, account/login pages (data colours
  stay orange = warm, blue = cool on the dashboard)
- Slim sticky top bar with location chip, gear (settings) and ? (learn); 4-tab nav
  (Now, Station, Map, Alerts) with a thumb-reach bottom bar on phones
- Hero sky gradient by condition/time with rain, storm, snow and star effects
  (respects reduced motion); keeps the searched city and badges when temps come from your station
- One-line summary, 'Next 2 hours' bars and rain countdown from 15-minute precipitation,
  rain/cold-aware 'Bring' chips; yesterday + 'vs usual' context from the station
- Scrubbable hourly chart (temp, rain chance, wind), 7-day range bars, AQI gauge,
  details tiles instead of drawers, taller radar with location marker + alert polygons
- Skeleton loading instead of a full-screen spinner, sticky section headers, safe areas
- Server: minutely_15 precipitation, public /api/station/summary

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:56:18 -04:00
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
Nate EmmertandClaude Sonnet 5.5 7dea2734ea Sessions: 180-day lifetime with sliding renewal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:40:15 -04:00
Nate EmmertandClaude Sonnet 5.5 1566ac2897 Add user management with admin and viewer roles
- data/users.json store; existing single account migrates to first admin
- Roles: admin (settings, import, users) and viewer (read-only, own password)
- Live role/disabled lookup; per-user session epoch so password changes,
  resets and disabling sign that user out everywhere
- Login brute-force lockout (5 fails -> 5 min), constant-time unknown-user check
- /api/users CRUD with last-admin and self-lockout safeguards
- Account page (own password + admin user management); role-aware header
- Min password length 8; recovery token resets the primary admin and clears lockouts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:34:13 -04:00
Nate EmmertandClaude Sonnet 5.5 b3f1061f5c Keep dashboard panels permanently expanded
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:20 -04:00
Nate EmmertandClaude Sonnet 5.5 1ad2dbb860 Redesign dashboard: tabs, insights, rain, sun/moon, zoomable charts
- Tab bar (bottom nav on phones) splitting Now / Charts / Rain / Records / Insights
- Unified tiles with vs-yesterday deltas, hero 24h trace, wind compass, today hi/lo
- Highlights chips, sun & moon card, station health card
- Charts: min/max envelope for long ranges (server-side bucketing), pinch/wheel zoom,
  pressure tendency, indoor vs outdoor, clear-sky solar overlay
- Rain tab: daily + cumulative, monthly totals; calendar heatmaps (temp/rain/gust)
- Insights: period trends, station normals, diurnal range, coverage, seasonal wind roses
- Server: /api/station/daily, history ?points= bucketing, 2d range, daily avg + wind
  histogram aggregates; fix dry days never recording rain=0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:25:08 -04:00
Nate EmmertandClaude Sonnet 5.5 a97d104d14 Persistent station records, backups, and iPhone-friendly dashboard
- Keep per-day extremes in data/records.json (merge-only) and derive all-time,
  notable-day, monthly and yearly records from it; rebuild from raw files at startup
- Use station maxdailygust for true gust extremes
- Add /api/station/backup.jsonl, records-backup.json and records/rebuild
- Dashboard: safe-area insets, 44px touch targets, scrollable nav/range bars,
  chart touch-action, standalone PWA manifest + apple meta tags

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:14:25 -04:00
Nate EmmertandClaude Sonnet 5.5 4c2c619fb6 Dedupe station readings and group rain stats by local day
- Share a disk-backed per-day dateutc set between live recorder and backfill
- Validate timestamps before saving; dedupe existing files at startup and on read
- Use station-local day (not UTC) for rain totals, streaks, degree days and
  monthly summary so dailyrainin resets line up (Sept rain was 4.37in vs 2.24in)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:10:11 -04:00
20 changed files with 2927 additions and 1393 deletions

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
/* Main app redesign layer: slim top bar, dynamic sky hero, nowcast, scrub chart, tiles, skeletons */
:root {
--sky-text: #ffffff;
--blue-grad: linear-gradient(160deg, #2563eb 0%, #3b82f6 55%, #93c5fd 100%);
--topbar-h: 58px;
}
html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
body { overscroll-behavior-y: none; }
button, a, summary { touch-action: manipulation; }
/* ── Layout ── */
.app { max-width: 980px; padding: 0 1rem 4rem; }
@media (min-width: 900px) { .app { padding: 0 1.5rem 4rem; } }
/* ── Slim top bar ── */
.topbar {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; gap: 0.75rem;
margin: 0 -1rem 1rem; padding: calc(0.55rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 0.55rem calc(1rem + env(safe-area-inset-left, 0px));
min-height: var(--topbar-h);
background: color-mix(in srgb, var(--paper) 86%, transparent);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
@media (min-width: 900px) { .topbar { margin: 0 -1.5rem 1.25rem; padding-left: 1.5rem; padding-right: 1.5rem; } }
.brand { display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: var(--ink); font-weight: 800; letter-spacing: -0.01em; font-size: 1.05rem; flex-shrink: 0; }
.brand-mark { color: var(--accent); font-size: 1.3rem; line-height: 1; }
.topbar .tabs { border: none; border-radius: 999px; background: var(--paper-dark); padding: 3px; gap: 2px; margin: 0 auto; overflow: visible; }
.topbar .tab { border: none; border-radius: 999px; min-height: 36px; padding: 0 1rem; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; color: var(--ink-light); position: relative; }
.topbar .tab:hover { color: var(--ink); }
.topbar .tab.active { background: var(--accent); color: #fff; }
.topbar-actions { display: flex; align-items: center; gap: 0.4rem; margin-left: auto; min-width: 0; }
.tabs + .topbar-actions { margin-left: 0; }
.loc-chip {
display: inline-flex; align-items: center; gap: 0.35rem; min-height: 40px; max-width: 46vw; padding: 0 0.85rem;
border: 1px solid var(--border); border-radius: 999px; background: var(--paper-dark); color: var(--ink);
font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
}
.loc-chip span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.loc-chip-ico { font-size: 1.1rem; line-height: 1; color: var(--accent); }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex-shrink: 0;
border: 1px solid var(--border); border-radius: 50%; background: var(--paper-dark); color: var(--ink-light);
font: inherit; font-size: 1.05rem; font-weight: 700; text-decoration: none; cursor: pointer; transition: color .15s, border-color .15s, background .15s;
}
.icon-btn:hover { color: var(--accent); border-color: var(--accent); }
.icon-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
@media (max-width: 640px) {
.app { padding: 0 0.9rem max(5.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))); }
.topbar { margin: 0 -0.9rem 0.8rem; }
.brand-name { display: none; }
.topbar .tabs { display: none; }
.loc-chip { flex: 1; max-width: none; justify-content: flex-start; }
.icon-btn[href="/dashboard"] { display: none; } /* reachable from Settings */
}
/* ── Bottom nav (4 tabs) in the shared palette ── */
.bottom-nav { background: color-mix(in srgb, var(--paper-dark) 94%, transparent); border-top: 1px solid var(--border); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); }
.bottom-tab { color: var(--ink-light); border-right: none; min-height: 58px; position: relative; }
.bottom-tab.active { color: var(--accent); background: transparent; }
.bottom-tab.active::before { content: ''; position: absolute; top: 0; left: 28%; right: 28%; height: 3px; border-radius: 0 0 3px 3px; background: var(--accent); }
.bottom-label { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
/* ── Location header ── */
.loc-header { background: transparent; border: none; padding: 0.1rem 0.15rem; margin-bottom: 0.6rem; }
.loc-name { font-size: 1.25rem; font-weight: 800; letter-spacing: -0.01em; }
.loc-sub, .loc-time { font-size: 0.78rem; color: var(--ink-light); }
/* ── Shared card surface ── */
.ui-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px; padding: 1rem 1.1rem; margin-bottom: 1.4rem; }
.ui-card .section-head { margin-bottom: 0.7rem; }
/* ── Dynamic sky hero ── */
.hero {
position: relative; overflow: hidden; isolation: isolate; color: var(--sky-text);
background: var(--blue-grad); border: none; border-radius: 22px; padding: 1.6rem 1.3rem; margin-bottom: 0.9rem;
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.18); transition: background .6s ease;
--card-sub: rgba(255,255,255,0.82); --card-ink: #fff;
}
.hero[data-sky="clear-day"] { background: linear-gradient(160deg, #1d4ed8 0%, #3b82f6 55%, #bae6fd 100%); }
.hero[data-sky="clear-night"] { background: linear-gradient(160deg, #0b1330 0%, #1e2a5a 60%, #3b2f6b 100%); box-shadow: 0 10px 30px rgba(11,19,48,.5); }
.hero[data-sky="cloudy-day"] { background: linear-gradient(160deg, #475569 0%, #64748b 55%, #94a3b8 100%); box-shadow: 0 10px 30px rgba(71,85,105,.25); }
.hero[data-sky="cloudy-night"]{ background: linear-gradient(160deg, #0f172a 0%, #1e293b 60%, #334155 100%); }
.hero[data-sky="rain"] { background: linear-gradient(160deg, #1e293b 0%, #334155 55%, #475569 100%); }
.hero[data-sky="storm"] { background: linear-gradient(160deg, #1a1033 0%, #2f2a55 55%, #45406b 100%); }
.hero[data-sky="snow"] { background: linear-gradient(160deg, #64748b 0%, #94a3b8 55%, #cbd5e1 100%); }
.hero[data-sky="fog"] { background: linear-gradient(160deg, #64748b 0%, #94a3b8 60%, #cbd5e1 100%); }
.sky-fx { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
.hero-temp { font-family: 'Barlow Condensed', var(--serif); font-weight: 800; font-size: clamp(5rem, 18vw, 7.5rem); letter-spacing: -0.02em; text-shadow: 0 2px 18px rgba(0,0,0,.18); }
.hero-temp .deg { font-size: 0.4em; opacity: .75; }
.hero .feels, .hero .hero-sub, .hero .hero-desc, .hilo-label { color: var(--sky-text); opacity: .92; }
.hero-desc { font-weight: 700; font-size: 1rem; }
.hero-icon { filter: drop-shadow(0 4px 10px rgba(0,0,0,.25)); }
.hero-hilo { background: rgba(255,255,255,0.14); border: 1px solid rgba(255,255,255,0.22); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border-radius: 14px; color: #fff; }
.hilo-divider { background: rgba(255,255,255,.3); }
.hero-share-btn { color: #fff; border-color: rgba(255,255,255,.45); background: rgba(255,255,255,.12); min-height: 36px; border-radius: 999px; }
.hero-share-btn:hover { background: rgba(255,255,255,.25); }
.hero-share-status { color: #fff; }
.station-badge { background: rgba(255,255,255,.18); color: #fff; border-color: rgba(255,255,255,.4); }
.hero-context { margin-top: 0.45rem; font-size: 0.8rem; font-weight: 600; color: #fff; background: rgba(0,0,0,.18); border-radius: 999px; padding: 0.25rem 0.8rem; display: inline-block; }
@media (max-width: 640px) {
.hero { grid-template-columns: 1fr auto; grid-template-areas: "center right" "left left"; padding: 1.2rem 1rem; gap: 0.7rem; }
.hero-center { grid-area: center; text-align: left; }
.hero-right { grid-area: right; }
.hero-left { grid-area: left; flex-direction: row; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.hero-icon { font-size: 2.2rem; }
.hero-hilo { flex-direction: column; }
.hilo-divider { width: 70%; height: 1px; }
}
/* ── Summary ── */
.summary-line { font-size: 1.02rem; font-weight: 700; line-height: 1.35; margin: 0 0.15rem 1.1rem; letter-spacing: -0.005em; }
.summary-line small { display: block; font-size: 0.8rem; font-weight: 500; color: var(--ink-light); margin-top: 0.15rem; }
.summary-line:empty { display: none; }
/* ── Nowcast ── */
.nowcast-bars { display: grid; grid-template-columns: repeat(8, 1fr); gap: 6px; align-items: end; height: 70px; margin-bottom: 0.35rem; }
.nc-col { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 3px; min-width: 0; }
.nc-bar { width: 100%; border-radius: 6px 6px 3px 3px; background: color-mix(in srgb, var(--cool) 28%, var(--paper-darker)); min-height: 5px; transition: height .4s ease; }
.nc-bar.wet { background: linear-gradient(180deg, #60a5fa, #2563eb); }
.nc-bar.heavy { background: linear-gradient(180deg, #818cf8, #4338ca); }
.nc-time { font-size: 0.62rem; color: var(--ink-light); font-variant-numeric: tabular-nums; white-space: nowrap; }
.nowcast-bars + .nowcast-bars { margin-top: 0; }
.bring-row { display: flex; align-items: flex-start; gap: 0.7rem; padding-top: 0.8rem; margin-top: 0.7rem; border-top: 1px solid var(--border); }
.bring-label { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-light); padding-top: 0.45rem; flex-shrink: 0; }
.bring-row .recs-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0; }
.bring-row .rec-item { flex-direction: row; gap: 0.4rem; padding: 0.35rem 0.75rem; border-radius: 999px; background: var(--paper-darker); border: 1px solid var(--border); }
.bring-row .rec-icon { font-size: 1.05rem; }
.bring-row .rec-label { font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
/* ── Sticky section headings (iPhone friendly) ── */
.sticky-head { position: sticky; top: calc(var(--topbar-h) + env(safe-area-inset-top, 0px) - 6px); z-index: 20; padding: 0.45rem 0.15rem; margin: 0 0 0.5rem;
background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.section-label { font-size: 0.78rem; letter-spacing: 0.12em; }
.link-btn { min-height: 36px; font-style: normal; color: var(--accent); }
/* ── Hourly scrub chart ── */
.hourly-card { padding: 0.8rem 0.9rem 0.6rem; margin-bottom: 0.7rem; }
.hc-readout { display: flex; flex-wrap: wrap; gap: 0.3rem 1rem; align-items: baseline; font-size: 0.82rem; min-height: 1.6rem; font-variant-numeric: tabular-nums; color: var(--ink-light); }
.hc-readout b { color: var(--ink); font-size: 1.15rem; }
.hc-readout .hc-t { font-weight: 700; color: var(--ink); min-width: 4.5rem; }
.hc-readout .hc-rain { color: #3b82f6; font-weight: 600; }
.hc-readout .hc-wind { color: var(--cool); font-weight: 600; }
.hourly-chart { width: 100%; touch-action: pan-y; user-select: none; -webkit-user-select: none; cursor: crosshair; }
.hourly-chart svg { display: block; width: 100%; height: auto; overflow: visible; }
.hourly-scroll { border-radius: 14px; margin-bottom: 1.6rem; }
.hour-cell { background: var(--card-bg); }
.hour-cell.now { background: color-mix(in srgb, var(--accent) 14%, var(--card-bg)); }
/* ── Radar ── */
.radar-embed-wrap { border-radius: 16px; margin-bottom: 1.6rem; border: 1px solid var(--border); }
.radar-map { height: 380px; }
@media (max-width: 640px) { .radar-map { height: 320px; } }
.radar-loc-marker { display: flex; flex-direction: column; align-items: center; gap: 4px; transform: translateY(-4px); pointer-events: none; }
.radar-loc-pin { width: 16px; height: 16px; border-radius: 50%; background: #2563eb; border: 3px solid #fff; box-shadow: 0 0 0 6px rgba(37,99,235,.25), 0 2px 6px rgba(0,0,0,.5); animation: pin-pulse 2.4s ease-out infinite; }
.radar-loc-label { background: rgba(7,12,20,.82); color: #fff; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
@keyframes pin-pulse { 0% { box-shadow: 0 0 0 0 rgba(37,99,235,.55), 0 2px 6px rgba(0,0,0,.5); } 70%,100% { box-shadow: 0 0 0 14px rgba(37,99,235,0), 0 2px 6px rgba(0,0,0,.5); } }
/* ── 7-day range bars ── */
.daily-list { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; background: var(--card-bg); margin-bottom: 1.6rem; }
.day-row { grid-template-columns: 5.2rem 2rem 3rem 2.2rem 1fr 2.2rem; gap: 0.5rem; padding: 0.7rem 1rem; border: none; border-bottom: 1px solid var(--border); border-radius: 0; background: transparent; }
.day-row:last-child { border-bottom: none; }
.day-row.today { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.day-name { font-weight: 700; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-icon { font-size: 1.4rem; text-align: center; }
.day-pop { font-size: 0.74rem; font-weight: 600; color: #3b82f6; white-space: nowrap; font-variant-numeric: tabular-nums; }
.pop-drop { font-size: 0.7rem; margin-right: 1px; }
.day-lo-t, .day-hi-t { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 0.95rem; text-align: right; }
.day-lo-t { color: var(--ink-light); font-weight: 600; }
.day-range { position: relative; height: 7px; border-radius: 99px; background: var(--paper-darker); }
.day-range-bar { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: linear-gradient(90deg, #38bdf8, #fbbf24 60%, #fb923c); }
@media (max-width: 640px) { .day-row { grid-template-columns: 3.7rem 1.8rem 2.6rem 2rem 1fr 2rem; gap: 0.35rem; padding: 0.65rem 0.8rem; } }
/* ── Detail tiles ── */
.detail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.65rem; margin-bottom: 1.6rem; }
.detail-grid .stat-card { border: 1px solid var(--border); border-radius: 16px; background: var(--card-bg); padding: 0.9rem 1rem; min-width: 0; }
.detail-grid .aqi-card, .detail-grid .sun-tile { grid-column: span 2; }
@media (max-width: 420px) { .detail-grid { grid-template-columns: repeat(2, 1fr); } }
.sun-tile .sun-timeline-wrap { margin-top: 0.4rem; }
.aqi-cat { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; padding: 0.1rem 0.5rem; border-radius: 99px; background: var(--paper-darker); vertical-align: middle; margin-left: 0.3rem; }
.aqi-track { position: relative; height: 8px; border-radius: 99px; margin: 0.45rem 0 0.35rem; background: linear-gradient(90deg, #22c55e 0%, #eab308 20%, #f97316 40%, #ef4444 60%, #a855f7 80%, #7f1d1d 100%); }
.aqi-marker { position: absolute; top: 50%; left: 0; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #fff; border: 3px solid var(--ink); box-shadow: 0 1px 4px rgba(0,0,0,.4); transition: left .5s ease; }
/* ── Skeleton loading (replaces the full-screen spinner) ── */
.loading { display: block; min-height: 0; padding: 0; }
.loading.hidden { display: none; }
body:not([data-tab="conditions"]) #loading { display: none; }
.sk { border-radius: 16px; background: linear-gradient(90deg, var(--paper-dark) 25%, var(--paper-darker) 50%, var(--paper-dark) 75%); background-size: 200% 100%; animation: sk-shimmer 1.4s infinite linear; }
.sk-hero { height: 190px; border-radius: 22px; margin-bottom: 0.9rem; }
.sk-line { height: 24px; width: 70%; margin-bottom: 1.1rem; border-radius: 8px; }
.sk-card { height: 120px; margin-bottom: 1.2rem; }
.sk-card.tall { height: 200px; }
.sk-rows { display: grid; gap: 8px; }
.sk-row { height: 44px; border-radius: 12px; }
@keyframes sk-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .sk, .radar-loc-pin, .nc-bar { animation: none; transition: none; } }
/* ── Page chrome ── */
.tab-panel { animation: panel-in .25s cubic-bezier(.2,0,0,1); }
@keyframes panel-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
.footer-note { color: var(--ink-light); }
.settings-wrap .icon-btn { display: none; }
/* High / low block: one tidy line each */
.hero-hilo { display: flex; align-items: center; gap: 0.2rem; padding: 0.55rem 0.8rem; }
.hilo-item { display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline; justify-content: center; font-size: 1.35rem; font-weight: 800; line-height: 1.1; text-align: center; min-width: 3.6rem; }
.hilo-arrow { display: inline; font-size: 0.8em; opacity: .85; margin-right: 1px; }
.hilo-label { flex-basis: 100%; font-size: 0.62rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 2px; }
@media (max-width: 640px) { .hero-hilo { flex-direction: column; gap: 0.35rem; } .hilo-divider { width: 70%; height: 1px; } }
+403
View File
@@ -0,0 +1,403 @@
/* Main app redesign layer — builds on globals from app.js (state, wmo, tempDisplay, radarEmbed, …).
app.js calls AppUI.onWeather / onAir / onStation after rendering. */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const us = () => state.units === 'us';
const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ── Tab tracking (drives skeleton visibility, top-bar state) ─────────────────
function currentTabName() {
return document.querySelector('.tab.active, .bottom-tab.active')?.dataset.tab || 'conditions';
}
document.body.dataset.tab = currentTabName();
const baseActivate = window.activateTab;
if (typeof baseActivate === 'function') {
window.activateTab = function (name) {
baseActivate(name);
document.body.dataset.tab = name;
window.scrollTo({ top: 0 });
if (name === 'conditions' && window.AppUI) setTimeout(window.AppUI.refreshFx, 30);
};
}
// ── Top bar: location chip opens search ──────────────────────────────────────
$('tb-search')?.addEventListener('click', () => {
window.activateTab('settings');
setTimeout(() => {
const input = $('search-input');
if (input) { input.scrollIntoView({ block: 'center', behavior: 'smooth' }); input.focus({ preventScroll: true }); }
}, 120);
});
function syncTopbarLocation() {
const name = $('loc-name')?.textContent?.trim();
if ($('tb-loc')) $('tb-loc').textContent = name || 'Location';
}
// ── Sky: gradient + precipitation / star effects ─────────────────────────────
function skyFor(code, isDay, stationRaining) {
if (stationRaining) return 'rain';
if (code >= 95) return 'storm';
if ([71, 73, 75, 77, 85, 86].includes(code)) return 'snow';
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return 'rain';
if ([45, 48].includes(code)) return 'fog';
if (code === 2 || code === 3) return isDay ? 'cloudy-day' : 'cloudy-night';
return isDay ? 'clear-day' : 'clear-night';
}
const fx = { raf: 0, kind: null, parts: [], w: 0, h: 0, flash: 0 };
function fxResize(canvas) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
fx.w = canvas.clientWidth; fx.h = canvas.clientHeight;
canvas.width = fx.w * dpr; canvas.height = fx.h * dpr;
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fxSeed(kind) {
const n = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0;
fx.parts = Array.from({ length: n }, () => ({
x: Math.random() * fx.w, y: Math.random() * fx.h,
v: kind === 'snow' ? 0.4 + Math.random() * 0.9 : kind === 'clear-night' ? 0 : 6 + Math.random() * 7,
r: kind === 'snow' ? 1 + Math.random() * 2.2 : kind === 'clear-night' ? 0.4 + Math.random() * 1.2 : 0.8 + Math.random() * 0.8,
p: Math.random() * Math.PI * 2,
}));
}
function fxFrame(t) {
const canvas = $('sky-fx');
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, fx.w, fx.h);
const kind = fx.kind;
if (kind === 'rain' || kind === 'storm') {
ctx.strokeStyle = 'rgba(200,220,255,0.55)'; ctx.lineWidth = 1.1; ctx.lineCap = 'round';
ctx.beginPath();
for (const p of fx.parts) {
ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - 2.2, p.y + p.v * 1.6);
p.y += p.v; p.x -= 0.55;
if (p.y > fx.h) { p.y = -10; p.x = Math.random() * (fx.w + 20); }
}
ctx.stroke();
if (kind === 'storm') {
if (fx.flash <= 0 && Math.random() < 0.004) fx.flash = 1;
if (fx.flash > 0) { ctx.fillStyle = `rgba(255,255,255,${(fx.flash * 0.28).toFixed(3)})`; ctx.fillRect(0, 0, fx.w, fx.h); fx.flash -= 0.07; }
}
} else if (kind === 'snow') {
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for (const p of fx.parts) {
ctx.beginPath(); ctx.arc(p.x + Math.sin(t / 900 + p.p) * 6, p.y, p.r, 0, Math.PI * 2); ctx.fill();
p.y += p.v; if (p.y > fx.h + 4) { p.y = -4; p.x = Math.random() * fx.w; }
}
} else if (kind === 'clear-night') {
for (const p of fx.parts) {
ctx.fillStyle = `rgba(255,255,255,${(0.35 + 0.55 * Math.abs(Math.sin(t / 1400 + p.p))).toFixed(2)})`;
ctx.beginPath(); ctx.arc(p.x, p.y * 0.8, p.r, 0, Math.PI * 2); ctx.fill();
}
}
if (!REDUCED && !document.hidden) fx.raf = requestAnimationFrame(fxFrame);
}
function setSky(kind) {
const hero = $('hero'), canvas = $('sky-fx');
if (!hero || !canvas) return;
hero.dataset.sky = kind;
cancelAnimationFrame(fx.raf);
fx.kind = kind;
fxResize(canvas);
fxSeed(kind);
if (fx.parts.length) fxFrame(performance.now());
else canvas.getContext('2d').clearRect(0, 0, fx.w, fx.h);
}
window.addEventListener('resize', () => { const c = $('sky-fx'); if (c && fx.kind) { fxResize(c); fxSeed(fx.kind); } });
document.addEventListener('visibilitychange', () => { if (!document.hidden && fx.parts.length && !REDUCED) { cancelAnimationFrame(fx.raf); fxFrame(performance.now()); } });
// ── Nowcast: next 2 hours from 15-minute precipitation ───────────────────────
function nowcastSlots(data) {
const m = data.minutely_15;
const now = Date.now();
if (m?.time?.length && m.precipitation) {
let i = m.time.findIndex((t) => new Date(t).getTime() + 15 * 60000 > now);
if (i < 0) i = 0;
const slots = [];
for (let k = i; k < m.time.length; k++) slots.push({ t: new Date(m.time[k]).getTime(), v: m.precipitation[k] || 0 });
if (slots.length >= 4) return { slots, source: '15min' };
}
// Fallback: hourly rain chance spread over 15-minute slots
const h = data.hourly;
let si = h.time.findIndex((t) => new Date(t).getTime() >= now - 3600000);
if (si < 0) si = 0;
const slots = [];
for (let k = si; k < Math.min(si + 6, h.time.length); k++) {
const t0 = new Date(h.time[k]).getTime();
const pp = (h.precipitation_probability?.[k] || 0) / 100;
const mm = h.rain?.[k] || 0;
for (let q = 0; q < 4; q++) slots.push({ t: t0 + q * 900000, v: pp >= 0.5 ? Math.max(mm / 4, us() ? 0.006 : 0.15) : 0, pp });
}
return { slots: slots.filter((s) => s.t + 900000 > now), source: 'hourly' };
}
const WET = () => (us() ? 0.004 : 0.1);
const HEAVY = () => (us() ? 0.04 : 1.0);
const mins = (ms) => Math.max(0, Math.round(ms / 60000));
function human(m) { return m >= 90 ? `${Math.round(m / 6) / 10}h`.replace('.0h', 'h') : `${m} min`; }
function rainOutlook(slots) {
const now = Date.now();
const wet = (s) => s.v >= WET();
const first = slots[0];
if (!slots.length) return { text: '', short: '' };
if (first && wet(first)) {
const dry = slots.findIndex((s) => !wet(s));
if (dry < 0) return { text: 'Rain continuing for the next few hours', short: 'Rain continuing', kind: 'raining' };
const end = mins(slots[dry].t - now);
return { text: `Rain ending in about ${human(end)}`, short: `Rain ends ~${human(end)}`, kind: 'raining' };
}
const idx = slots.findIndex(wet);
if (idx < 0) return { text: `No rain expected in the next ${Math.round((slots[slots.length - 1].t + 900000 - now) / 3600000)} hours`, short: 'Dry ahead', kind: 'dry' };
const start = mins(slots[idx].t - now);
let len = 0; for (let k = idx; k < slots.length && wet(slots[k]); k++) len += 15;
const reachesEnd = idx + len / 15 >= slots.length;
const dur = reachesEnd ? `${human(len)}+` : `about ${human(len)}`;
const when = start <= 5 ? 'Rain starting now' : `Rain starting in ${human(start)}`;
return { text: `${when}, lasting ${dur}`, short: start <= 5 ? 'Rain now' : `Rain in ${human(start)}`, kind: 'soon', start };
}
function renderNowcast(data) {
const host = $('nowcast-bars');
if (!host) return { text: '', short: '' };
const { slots, source } = nowcastSlots(data);
const next8 = slots.slice(0, 8);
const scale = Math.max(HEAVY() * 1.5, ...next8.map((s) => s.v));
host.innerHTML = next8.map((s, i) => {
const hPct = s.v >= WET() ? Math.max(14, Math.min(100, (s.v / scale) * 100)) : 7;
const cls = s.v >= HEAVY() ? 'heavy' : s.v >= WET() ? 'wet' : '';
const label = i === 0 ? 'Now' : i % 2 === 0 ? new Date(s.t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(' ', '').toLowerCase() : '';
const unit = us() ? 'in' : 'mm';
return `<div class="nc-col" title="${new Date(s.t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}: ${s.v >= WET() ? s.v.toFixed(us() ? 2 : 1) + ' ' + unit : 'dry'}"><div class="nc-bar ${cls}" style="height:${hPct}%"></div><div class="nc-time">${label}</div></div>`;
}).join('');
const outlook = rainOutlook(slots);
$('nowcast-note').textContent = source === '15min' ? outlook.short : `${outlook.short} · hourly est.`;
return outlook;
}
// ── "Bring" chips react to the rain outlook and the coming cold ──────────────
function enrichBring(data, outlook) {
const grid = $('recommendations');
if (!grid) return;
const have = (label) => [...grid.querySelectorAll('.rec-label')].some((e) => e.textContent.toLowerCase().includes(label));
const add = (icon, label) => grid.insertAdjacentHTML('afterbegin', `<div class="rec-item"><span class="rec-icon">${icon}</span><span class="rec-label">${label}</span></div>`);
const h = data.hourly;
let si = h.time.findIndex((t) => new Date(t).getTime() >= Date.now());
if (si < 0) si = 0;
const next12 = h.temperature_2m.slice(si, si + 12);
const coldF = (t) => (us() ? t : t * 9 / 5 + 32);
if (next12.length && coldF(Math.min(...next12)) <= 50 && !have('jacket') && !have('coat')) add('🧥', 'Jacket');
if ((outlook?.kind === 'soon' || outlook?.kind === 'raining') && !have('umbrella')) add('☂️', 'Umbrella');
}
// ── Summary line ─────────────────────────────────────────────────────────────
function renderSummary(data, outlook) {
const el = $('summary-line');
if (!el) return;
const c = data.current, d = data.daily;
const w = wmo(c.weather_code);
const hi = tempDisplay(d.temperature_2m_max[0]), lo = tempDisplay(d.temperature_2m_min[0]);
const parts = [];
if (outlook?.kind === 'soon' || outlook?.kind === 'raining') parts.push(`${outlook.text}.`);
else parts.push(`${w.desc} now, high ${hi}°, low ${lo}°.`);
const wind = c.wind_speed_10m;
const windy = (us() ? wind >= 18 : wind >= 29);
const subs = [];
if (outlook?.kind === 'soon' || outlook?.kind === 'raining') subs.push(`${w.desc} · high ${hi}°, low ${lo}°`);
if (windy) subs.push(`Breezy: ${speedDisplay(wind)} ${speedUnit()}${c.wind_gusts_10m ? `, gusts ${speedDisplay(c.wind_gusts_10m)}` : ''}`);
const nAlerts = state.currentAlerts?.features?.length || 0;
if (nAlerts) subs.push(`⚠ ${nAlerts} active alert${nAlerts > 1 ? 's' : ''} — see Alerts`);
el.innerHTML = `${parts.join(' ')}${subs.length ? `<small>${subs.join(' · ')}</small>` : ''}`;
}
// ── Hourly scrub chart (SVG, no dependencies) ────────────────────────────────
function renderHourlyChart(data) {
const host = $('hourly-chart'), readout = $('hc-readout');
if (!host) return;
const h = data.hourly;
let start = h.time.findIndex((t) => new Date(t).getTime() >= Date.now() - 1800000);
if (start < 0) start = 0;
const n = Math.min(state.hourlyExtended ? 48 : 24, h.time.length - start);
const idx = Array.from({ length: n }, (_, i) => start + i);
const feels = state.hourlyFeelsLike;
const temp = idx.map((i) => (feels ? h.apparent_temperature?.[i] ?? h.temperature_2m[i] : h.temperature_2m[i]));
const pop = idx.map((i) => h.precipitation_probability?.[i] ?? 0);
const wind = idx.map((i) => h.wind_speed_10m?.[i] ?? 0);
const times = idx.map((i) => new Date(h.time[i]));
const W = 640, H = 200, L = 34, R = 12, T = 14, B = 26;
const pw = W - L - R, ph = H - T - B;
const tMin = Math.floor(Math.min(...temp) - 2), tMax = Math.ceil(Math.max(...temp) + 2);
const wMax = Math.max(15, ...wind);
const x = (i) => L + (n === 1 ? 0 : (i / (n - 1)) * pw);
const yT = (v) => T + (1 - (v - tMin) / (tMax - tMin)) * ph;
const yW = (v) => T + (1 - v / wMax) * ph;
const barW = Math.max(3, pw / n - 3);
const bars = pop.map((p, i) => p > 0 ? `<rect x="${(x(i) - barW / 2).toFixed(1)}" y="${(T + ph - (p / 100) * ph * 0.55).toFixed(1)}" width="${barW.toFixed(1)}" height="${((p / 100) * ph * 0.55).toFixed(1)}" rx="2" fill="#3b82f6" opacity="${(0.25 + p / 160).toFixed(2)}"/>` : '').join('');
const line = (arr, yf) => arr.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${yf(v).toFixed(1)}`).join('');
const tLine = line(temp, yT);
const area = `${tLine}L${x(n - 1).toFixed(1)},${T + ph}L${x(0).toFixed(1)},${T + ph}Z`;
// x-axis labels + midnight dividers
let xl = '';
const step = n > 30 ? 6 : 3;
times.forEach((t, i) => {
const mid = t.getHours() === 0;
if (mid && i > 0) xl += `<line x1="${x(i).toFixed(1)}" x2="${x(i).toFixed(1)}" y1="${T}" y2="${T + ph}" stroke="currentColor" opacity=".25" stroke-dasharray="3 4"/><text x="${(x(i) + 4).toFixed(1)}" y="${T + 10}" font-size="10" font-weight="700" fill="currentColor" opacity=".6">${times[i].toLocaleDateString([], { weekday: 'short' })}</text>`;
if (i % step === 0) xl += `<text x="${x(i).toFixed(1)}" y="${H - 8}" font-size="10" text-anchor="middle" fill="currentColor" opacity=".6">${i === 0 ? 'Now' : t.toLocaleTimeString([], { hour: 'numeric' }).replace(' ', '').toLowerCase()}</text>`;
});
// y-axis (temperature)
let yl = '';
for (let k = 0; k <= 3; k++) {
const v = tMin + ((tMax - tMin) * k) / 3, yy = yT(v);
yl += `<line x1="${L}" x2="${W - R}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}" stroke="currentColor" opacity=".1"/><text x="${L - 6}" y="${(yy + 3).toFixed(1)}" font-size="10" text-anchor="end" fill="currentColor" opacity=".6">${tempDisplay(v)}°</text>`;
}
host.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" style="color:var(--ink-light)">
<defs><linearGradient id="hcg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fb923c" stop-opacity=".35"/><stop offset="1" stop-color="#fb923c" stop-opacity="0"/></linearGradient></defs>
${yl}${bars}
<path d="${line(wind, yW)}" fill="none" stroke="#38bdf8" stroke-width="1.6" stroke-dasharray="4 3" opacity=".85"/>
<path d="${area}" fill="url(#hcg)"/>
<path d="${tLine}" fill="none" stroke="#fb923c" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/>
${xl}
<g id="hc-cursor" style="display:none">
<line id="hc-line" y1="${T}" y2="${T + ph}" stroke="currentColor" opacity=".55"/>
<circle id="hc-dot" r="5" fill="#fb923c" stroke="#fff" stroke-width="2"/>
</g></svg>`;
const svg = host.querySelector('svg');
const fmtT = (d) => d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: 'numeric' });
const show = (i) => {
const code = h.weather_code?.[idx[i]];
readout.innerHTML = `<span class="hc-t">${i === 0 ? 'Now' : fmtT(times[i])}</span><span>${code != null ? wmo(code).icon : ''} <b>${tempDisplay(temp[i])}°</b></span><span class="hc-rain">💧 ${pop[i]}%</span><span class="hc-wind">💨 ${speedDisplay(wind[i])} ${speedUnit()}</span>`;
const cur = svg.querySelector('#hc-cursor');
cur.style.display = '';
svg.querySelector('#hc-line').setAttribute('x1', x(i)); svg.querySelector('#hc-line').setAttribute('x2', x(i));
svg.querySelector('#hc-dot').setAttribute('cx', x(i)); svg.querySelector('#hc-dot').setAttribute('cy', yT(temp[i]));
};
const move = (ev) => {
const r = svg.getBoundingClientRect();
const vx = ((ev.clientX - r.left) / r.width) * W;
show(Math.max(0, Math.min(n - 1, Math.round(((vx - L) / pw) * (n - 1)))));
};
svg.addEventListener('pointermove', move);
svg.addEventListener('pointerdown', move);
const reset = () => { svg.querySelector('#hc-cursor').style.display = 'none'; show(0); svg.querySelector('#hc-cursor').style.display = 'none'; };
svg.addEventListener('pointerleave', reset);
svg.addEventListener('pointercancel', reset);
show(0); svg.querySelector('#hc-cursor').style.display = 'none';
}
// ── Air quality gauge + UV category ──────────────────────────────────────────
function aqiInfo(v) {
if (v == null) return ['', '#94a3b8'];
if (v <= 50) return ['Good', '#22c55e'];
if (v <= 100) return ['Moderate', '#eab308'];
if (v <= 150) return ['Sensitive', '#f97316'];
if (v <= 200) return ['Unhealthy', '#ef4444'];
if (v <= 300) return ['Very unhealthy', '#a855f7'];
return ['Hazardous', '#991b1b'];
}
function uvCategory(uv) {
if (uv == null) return '';
return uv < 3 ? 'Low' : uv < 6 ? 'Moderate' : uv < 8 ? 'High' : uv < 11 ? 'Very high' : 'Extreme';
}
function renderAqi(air) {
const v = num(air?.current?.us_aqi);
const [label, color] = aqiInfo(v);
const cat = $('aqi-cat'), marker = $('aqi-marker');
if (cat) { cat.textContent = label; cat.style.color = color; }
if (marker) marker.style.left = v == null ? '0%' : `${Math.min(100, (v / 300) * 100).toFixed(1)}%`;
}
// ── Station context (yesterday / normal) ─────────────────────────────────────
let summaryCache = { at: 0, data: null };
async function loadStationSummary() {
if (summaryCache.data && Date.now() - summaryCache.at < 5 * 60000) return summaryCache.data;
try {
const r = await fetch('/api/station/summary');
if (!r.ok) return null;
summaryCache = { at: Date.now(), data: await r.json() };
return summaryCache.data;
} catch { return null; }
}
async function renderStationContext() {
const el = $('hero-context');
if (!el) return;
const s = await loadStationSummary();
const y = s?.yesterday;
if (!y || y.hi == null || y.lo == null) { el.classList.add('hidden'); return; }
const conv = (f) => (us() ? Math.round(f) : Math.round((f - 32) * 5 / 9));
let text = `Yesterday ${conv(y.hi)}° / ${conv(y.lo)}°`;
const todayHi = state.currentWeather?.daily?.temperature_2m_max?.[0];
if (s.normal?.hi != null && todayHi != null) {
const normHi = us() ? s.normal.hi : (s.normal.hi - 32) * 5 / 9;
const diff = Math.round(todayHi - normHi);
if (Math.abs(diff) >= 1) text += ` · ${Math.abs(diff)}° ${diff > 0 ? 'warmer' : 'cooler'} than usual today`;
else text += ' · about normal for today';
}
el.textContent = text;
el.classList.remove('hidden');
}
// ── Radar: location marker with rain countdown + alert polygons ──────────────
let radarMarker = null, radarMarkerEl = null;
function updateRadar(city, outlook) {
const map = typeof radarEmbed !== 'undefined' ? radarEmbed?.map : null;
if (!map || typeof maplibregl === 'undefined' || city?.latitude == null) return;
const label = outlook?.kind === 'soon' || outlook?.kind === 'raining' ? `📍 ${outlook.short}` : `📍 ${city.name || 'You'}`;
if (!radarMarker) {
radarMarkerEl = document.createElement('div');
radarMarkerEl.className = 'radar-loc-marker';
radarMarkerEl.innerHTML = '<div class="radar-loc-label"></div><div class="radar-loc-pin"></div>';
radarMarker = new maplibregl.Marker({ element: radarMarkerEl, anchor: 'bottom' }).setLngLat([city.longitude, city.latitude]).addTo(map);
} else radarMarker.setLngLat([city.longitude, city.latitude]);
radarMarkerEl.querySelector('.radar-loc-label').textContent = label;
const feats = (state.currentAlerts?.features || []).filter((f) => f.geometry);
const apply = () => {
try {
const fc = { type: 'FeatureCollection', features: feats };
if (map.getSource('ui-alerts')) { map.getSource('ui-alerts').setData(fc); return; }
if (!feats.length) return;
map.addSource('ui-alerts', { type: 'geojson', data: fc });
map.addLayer({ id: 'ui-alerts-fill', type: 'fill', source: 'ui-alerts', paint: { 'fill-color': '#ef4444', 'fill-opacity': 0.14 } });
map.addLayer({ id: 'ui-alerts-line', type: 'line', source: 'ui-alerts', paint: { 'line-color': '#ef4444', 'line-width': 2, 'line-dasharray': [2, 1] } });
} catch { /* style swapped mid-update; next refresh retries */ }
};
if (map.isStyleLoaded()) apply(); else map.once('idle', apply);
}
// ── Public hooks ─────────────────────────────────────────────────────────────
let lastOutlook = null;
window.AppUI = {
onWeather(data, city) {
syncTopbarLocation();
const peek = $('wind-drawer-peek'); if (peek) peek.textContent = peek.textContent.replace(/^[\s·]+/, '');
const c = data.current;
const d = state.ambientDevice?.lastData;
const stationFresh = !!(d?.dateutc && Date.now() - d.dateutc < 15 * 60000 && state.ambientUseStation);
if (!stationFresh) { $('station-badge')?.classList.add('hidden'); $('hero-context')?.classList.add('hidden'); }
setSky(skyFor(c.weather_code, !!c.is_day, stationFresh && d.rainratein > 0));
$('uv-cat').textContent = uvCategory(data.daily?.uv_index_max?.[0]);
lastOutlook = renderNowcast(data);
enrichBring(data, lastOutlook);
if ((lastOutlook?.kind === 'soon' || lastOutlook?.kind === 'raining') && $('cur-precip')) $('cur-precip').textContent = lastOutlook.short;
renderSummary(data, lastOutlook);
renderHourlyChart(data);
updateRadar(city, lastOutlook);
if (state.currentAir) renderAqi(state.currentAir);
if (stationFresh) renderStationContext();
},
onAir(air) { renderAqi(air); },
refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fxResize(c); fxSeed(fx.kind); if (fx.parts.length) fxFrame(performance.now()); } },
onStation() { syncTopbarLocation(); renderStationContext(); },
};
syncTopbarLocation();
new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true });
})();
+31 -104
View File
@@ -746,7 +746,7 @@ function renderSavedCities() {
// ── Tab switching ───────────────────────────────────────────────────────────── // ── Tab switching ─────────────────────────────────────────────────────────────
function activateTab(tabName) { function activateTab(tabName) {
document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab, .bottom-tab, .icon-btn[data-tab]').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => hide(p)); document.querySelectorAll('.tab-panel').forEach(p => hide(p));
document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active')); document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active'));
show(document.getElementById('tab-' + tabName)); show(document.getElementById('tab-' + tabName));
@@ -768,7 +768,7 @@ function activateTab(tabName) {
refreshSavedCityWeather(); refreshSavedCityWeather();
} }
} }
document.querySelectorAll('.tab, .bottom-tab').forEach(btn => { document.querySelectorAll('.tab, .bottom-tab, .icon-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab)); btn.addEventListener('click', () => activateTab(btn.dataset.tab));
}); });
@@ -1609,6 +1609,8 @@ function renderWeather(data, city) {
// Apply station overlay if we have fresh station data // Apply station overlay if we have fresh station data
if (state.ambientDevice) applyStationOverlay(state.ambientDevice); if (state.ambientDevice) applyStationOverlay(state.ambientDevice);
if (window.AppUI) window.AppUI.onWeather(data, city);
} }
function renderDailyForecast(daily) { function renderDailyForecast(daily) {
@@ -1619,16 +1621,22 @@ function renderDailyForecast(daily) {
const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays); const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays);
list.innerHTML = ''; list.innerHTML = '';
const hiAll = daily.temperature_2m_max.slice(0, maxDays), loAll = daily.temperature_2m_min.slice(0, maxDays);
const wkMin = Math.min(...loAll), wkMax = Math.max(...hiAll), span = Math.max(wkMax - wkMin, 1);
for (let i = 0; i < maxDays; i++) { for (let i = 0; i < maxDays; i++) {
const dw = wmo(daily.weather_code[i]); const dw = wmo(daily.weather_code[i]);
const lo = daily.temperature_2m_min[i], hi = daily.temperature_2m_max[i];
const left = ((lo - wkMin) / span) * 100, width = Math.max(((hi - lo) / span) * 100, 6);
const pop = daily.precipitation_probability_max[i];
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'day-row' + (i === 0 ? ' today' : ''); row.className = 'day-row' + (i === 0 ? ' today' : '');
row.innerHTML = ` row.innerHTML = `
<div class="day-name">${formatDayShort(daily.time[i])}</div> <div class="day-name">${formatDayShort(daily.time[i])}</div>
<div class="day-icon">${dw.icon}</div> <div class="day-icon" title="${dw.desc}">${dw.icon}</div>
<div class="day-desc">${dw.desc}</div> <div class="day-pop">${pop > 0 ? `<span class="pop-drop">💧</span>${pop}%` : ''}</div>
<div class="day-temps">${tempDisplay(daily.temperature_2m_max[i])}°<span class="day-lo"> / ${tempDisplay(daily.temperature_2m_min[i])}°</span></div> <div class="day-lo-t">${tempDisplay(lo)}°</div>
<div class="day-meta">${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain<br>' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}</div> <div class="day-range" title="${dw.desc} · wind ${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}"><span class="day-range-bar" style="left:${left.toFixed(1)}%;width:${width.toFixed(1)}%"></span></div>
<div class="day-hi-t">${tempDisplay(hi)}°</div>
`; `;
list.appendChild(row); list.appendChild(row);
} }
@@ -1661,6 +1669,7 @@ function renderAirQuality(data) {
if (panelAqi) panelAqi.textContent = cur.us_aqi ?? '--'; if (panelAqi) panelAqi.textContent = cur.us_aqi ?? '--';
setMapOverlayReadouts({ aqi: cur.us_aqi ?? '--' }); setMapOverlayReadouts({ aqi: cur.us_aqi ?? '--' });
if (window.AppUI) window.AppUI.onAir(data);
} }
function normalizeSeverity(severity) { function normalizeSeverity(severity) {
@@ -2658,7 +2667,7 @@ syncOfflineState();
// ── Swipe gestures for tab switching ────────────────────────────────────────── // ── Swipe gestures for tab switching ──────────────────────────────────────────
(function initSwipe() { (function initSwipe() {
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings']; const TABS = ['conditions', 'station', 'unified-map', 'alerts'];
let touchX = 0; let touchX = 0;
let touchY = 0; let touchY = 0;
let blocked = false; let blocked = false;
@@ -2813,7 +2822,9 @@ if (ambientSaveBtn) {
} }
// On load, check if server already has keys configured (other device saved them) // On load, check if server already has keys configured (other device saved them)
fetch('/api/ambient-config').then(r => r.json()).then(({ configured }) => { fetch('/api/ambient-config').then(r => r.json()).then(({ configured, stationName }) => {
if (stationName) state.stationName = stationName;
if (!configured && !state.ambientApiKey && typeof window.stationTabMessage === 'function') window.stationTabMessage('unconfigured');
if (configured && !state.ambientApiKey) { if (configured && !state.ambientApiKey) {
if (ambientStatusEl) { ambientStatusEl.textContent = '✓ Keys configured on server'; show(ambientStatusEl); } if (ambientStatusEl) { ambientStatusEl.textContent = '✓ Keys configured on server'; show(ambientStatusEl); }
fetchAmbientStation(); fetchAmbientStation();
@@ -2968,10 +2979,7 @@ if (pwsSaveBtn) {
let stationRefreshTimer = null; let stationRefreshTimer = null;
async function fetchAmbientStation() { async function fetchAmbientStation() {
const loadingEl = document.getElementById('station-loading'); const tabMsg = (kind, detail) => { if (typeof window.stationTabMessage === 'function') window.stationTabMessage(kind, detail); };
const contentEl = document.getElementById('station-content');
const errorEl = document.getElementById('station-error');
const peekEl = document.getElementById('station-drawer-peek');
const apiKey = state.ambientApiKey; const apiKey = state.ambientApiKey;
const appKey = state.ambientAppKey; const appKey = state.ambientAppKey;
@@ -2984,18 +2992,14 @@ async function fetchAmbientStation() {
const r = await fetch(`/api/ambient${qs}`); const r = await fetch(`/api/ambient${qs}`);
if (!r.ok) { if (!r.ok) {
const { error } = await r.json().catch(() => ({})); const { error } = await r.json().catch(() => ({}));
if (errorEl) { errorEl.textContent = error || 'Station unavailable'; show(errorEl); } tabMsg(r.status === 503 ? 'unconfigured' : 'error', error || 'Station unavailable');
if (loadingEl) hide(loadingEl);
return; return;
} }
const devices = await r.json(); const devices = await r.json();
if (!Array.isArray(devices) || !devices.length) { if (!Array.isArray(devices) || !devices.length) {
if (errorEl) { errorEl.textContent = 'No devices found on this account.'; show(errorEl); } tabMsg('error', 'No devices found on this account.');
if (loadingEl) hide(loadingEl);
return; return;
} }
if (loadingEl) hide(loadingEl);
if (errorEl) hide(errorEl);
renderAmbientStation(devices[0]); renderAmbientStation(devices[0]);
// Auto-refresh every 60 s // Auto-refresh every 60 s
@@ -3014,8 +3018,7 @@ async function fetchAmbientStation() {
} catch {} } catch {}
}, 60000); }, 60000);
} catch (e) { } catch (e) {
if (errorEl) { errorEl.textContent = 'Could not reach station.'; show(errorEl); } tabMsg('error', 'Could not reach your station.');
if (loadingEl) hide(loadingEl);
} }
} }
@@ -3075,14 +3078,6 @@ function applyStationOverlay(device) {
if (data.uv != null) setEl('cur-uv', data.uv); if (data.uv != null) setEl('cur-uv', data.uv);
const indoorCard = document.getElementById('st-indoor-card');
if (data.tempinf != null) {
setEl('st-tempin', fmtTemp(data.tempinf));
setEl('st-tempin-unit', isMetric ? 'C' : 'F');
setEl('st-humin', data.humidityin != null ? `${data.humidityin}% RH` : '');
if (indoorCard) indoorCard.classList.remove('hidden');
}
// Rain summary in hero sub-line // Rain summary in hero sub-line
const rainEl = document.getElementById('cur-precip'); const rainEl = document.getElementById('cur-precip');
if (rainEl && data.dailyrainin != null) { if (rainEl && data.dailyrainin != null) {
@@ -3099,87 +3094,19 @@ function applyStationOverlay(device) {
peekEl.textContent = `· ${fS(data.windspeedmph)} ${speedUnit()} ${compassDir(data.winddir || 0)} · ${data.humidity ?? '--'}% humidity`; peekEl.textContent = `· ${fS(data.windspeedmph)} ${speedUnit()} ${compassDir(data.winddir || 0)} · ${data.humidity ?? '--'}% humidity`;
} }
// Replace location name with station name // Keep the searched location's name; mark that temperatures now come from your own station
const stationName = device.info?.name || device.macAddress || 'My Station'; const stationName = (state.stationName || device.info?.name || 'My Station');
const locNameEl = document.getElementById('loc-name');
const locSubEl = document.getElementById('loc-sub');
if (locNameEl) locNameEl.textContent = stationName;
if (locSubEl) locSubEl.textContent = 'Ambient Weather Station';
// Hide the hero badge (redundant — location header now identifies the station)
const badge = document.getElementById('station-badge'); const badge = document.getElementById('station-badge');
if (badge) hide(badge); const badgeName = document.getElementById('station-badge-name');
if (badgeName) badgeName.textContent = `Your station · ${stationName}`;
if (badge) show(badge);
if (window.AppUI) window.AppUI.onStation(device, data);
} }
function renderAmbientStation(device) { function renderAmbientStation(device) {
state.ambientDevice = device; state.ambientDevice = device;
const data = device?.lastData || {}; // The Station tab (station-live.js) renders the readings
const info = device?.info || {}; if (typeof window.renderStationTab === 'function') window.renderStationTab(device);
const contentEl = document.getElementById('station-content');
const peekEl = document.getElementById('station-drawer-peek');
const stName = info.name || device?.macAddress || 'My Station';
const nameEl = document.getElementById('station-name');
if (nameEl) nameEl.textContent = stName;
const updatedEl = document.getElementById('station-updated');
if (updatedEl && data.dateutc) {
const ago = Math.round((Date.now() - data.dateutc) / 60000);
updatedEl.textContent = ago <= 1 ? 'just now' : `${ago} min ago`;
}
const isMetric = state.units !== 'us';
function fmtTemp(f) {
if (f == null) return '--';
return isMetric ? Math.round((f - 32) * 5 / 9) : Math.round(f);
}
function fmtSpeed(mph) {
if (mph == null) return '--';
return isMetric ? Math.round(mph * 1.60934) : Math.round(mph);
}
function fmtRain(inches) {
if (inches == null) return '--';
return isMetric ? (inches * 25.4).toFixed(1) : inches.toFixed(2);
}
const tempUnit = isMetric ? 'C' : 'F';
const speedUnit = isMetric ? 'km/h' : 'mph';
const rainUnit = isMetric ? 'mm' : 'in';
const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
setEl('st-temp', fmtTemp(data.tempf));
setEl('st-temp-unit', tempUnit);
setEl('st-feels', data.feelsLike != null ? `Feels ${fmtTemp(data.feelsLike)}°` : '');
setEl('st-hum', data.humidity != null ? `${data.humidity}` : '--');
if (data.tempf != null && data.humidity != null) {
const tc = (data.tempf - 32) * 5 / 9;
const alpha = Math.log(data.humidity / 100) + (17.62 * tc) / (243.12 + tc);
const dewC = (243.12 * alpha) / (17.62 - alpha);
const dewDisplay = isMetric ? Math.round(dewC) : Math.round(dewC * 9 / 5 + 32);
setEl('st-dew', `Dew ${dewDisplay}°`);
}
setEl('st-wind', fmtSpeed(data.windspeedmph));
setEl('st-wind-unit', speedUnit);
setEl('st-wind-dir', data.winddir != null ? compassDirFull(data.winddir) : '');
setEl('st-gust', fmtSpeed(data.windgustmph));
setEl('st-gust-unit', speedUnit);
setEl('st-rain-day', fmtRain(data.dailyrainin));
setEl('st-rain-unit', rainUnit);
const rrDisplay = data.hourlyrainin != null ? `${fmtRain(data.hourlyrainin)} ${rainUnit}/hr` : '';
setEl('st-rain-rate', rrDisplay);
setEl('st-pressure', data.baromrelin != null ? data.baromrelin.toFixed(2) : '--');
setEl('st-uv', data.uv != null ? data.uv : '--');
setEl('st-solar', data.solarradiation != null ? Math.round(data.solarradiation) : '--');
if (peekEl && data.tempf != null) {
peekEl.textContent = `· ${fmtTemp(data.tempf)}°${tempUnit} ${data.humidity != null ? data.humidity + '% RH' : ''}`;
}
if (contentEl) show(contentEl);
// Update the main conditions display with real station readings // Update the main conditions display with real station readings
if (state.currentWeather) applyStationOverlay(device); if (state.currentWeather) applyStationOverlay(device);
-735
View File
@@ -1,735 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Current Conditions</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Tokens: light ─────────────────────────────────────── */
:root {
--bg: #edf2f7;
--surface: #ffffff;
--card: #f4f7fb;
--border: #ccd6e2;
--text: #0d1b2a;
--muted: #5a7089;
--accent: #2563eb;
--ring: rgba(37,99,235,0.13);
--dot: rgba(37,99,235,0.065);
--live: #16a34a;
--north: #dc2626;
}
/* ── Tokens: dark ──────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #070c14;
--surface: #0d1720;
--card: #101c29;
--border: #192e44;
--text: #dce6f0;
--muted: #496070;
--accent: #60a5fa;
--ring: rgba(96,165,250,0.14);
--dot: rgba(96,165,250,0.07);
--live: #4ade80;
--north: #f87171;
}
}
:root[data-theme="dark"] {
--bg:#070c14; --surface:#0d1720; --card:#101c29; --border:#192e44;
--text:#dce6f0; --muted:#496070; --accent:#60a5fa;
--ring:rgba(96,165,250,0.14); --dot:rgba(96,165,250,0.07);
--live:#4ade80; --north:#f87171;
}
html, body {
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Barlow', system-ui, sans-serif;
}
/* ── Header ─────────────────────────────────────────────── */
.hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 1rem;
}
.hdr-left {
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
}
.hdr-back {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--muted);
text-decoration: none;
white-space: nowrap;
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);
}
.hdr-back: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);
}
.hdr-back:active { transform: scale(0.95); box-shadow: none; }
.hdr-sep {
color: var(--border);
font-size: 0.8rem;
flex-shrink: 0;
}
.hdr-station {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hdr-live {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--live);
white-space: nowrap;
}
.live-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--live);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:.45; transform:scale(.8); }
}
.hdr-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.hdr-time {
font-size: 0.78rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.hdr-dash-link {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
text-decoration: none;
white-space: nowrap;
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);
}
.hdr-dash-link: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);
}
.hdr-dash-link:active { transform: scale(0.95); box-shadow: none; }
.hdr-dash-link .arr {
display: inline-block;
transition: transform .18s cubic-bezier(.2,0,0,1);
}
.hdr-dash-link:hover .arr { transform: translateX(2px); }
/* ── Hero ────────────────────────────────────────────────── */
.hero {
position: relative;
background: var(--surface);
padding: 3.5rem 1.5rem 3.25rem;
text-align: center;
overflow: hidden;
border-bottom: 1px solid var(--border);
}
.hero-dots {
position: absolute; inset: 0;
background-image: radial-gradient(circle, var(--dot) 1.5px, transparent 1.5px);
background-size: 28px 28px;
pointer-events: none;
}
/* Radar sweep ring */
.hero-sweep {
position: absolute;
top: 50%; left: 50%;
width: 320px; height: 320px;
margin: -160px 0 0 -160px;
border-radius: 50%;
background: conic-gradient(from 0deg, transparent 78%, var(--accent) 100%);
opacity: .18;
animation: sweep 14s linear infinite;
pointer-events: none;
}
.hero-ring {
position: absolute;
top: 50%; left: 50%;
width: 400px; height: 400px;
margin: -200px 0 0 -200px;
border-radius: 50%;
border: 1px solid var(--border);
pointer-events: none;
}
.hero-ring2 {
position: absolute;
top: 50%; left: 50%;
width: 240px; height: 240px;
margin: -120px 0 0 -120px;
border-radius: 50%;
border: 1px solid var(--border);
pointer-events: none;
}
@keyframes sweep {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.hero-sweep { animation: none; }
.live-dot { animation: none; }
}
.hero-body { position: relative; z-index: 1; }
.temp-row {
display: inline-flex;
align-items: flex-start;
line-height: 1;
font-family: 'Barlow Condensed', sans-serif;
font-weight: 800;
letter-spacing: -0.03em;
}
.temp-num {
font-size: clamp(5.5rem, 22vw, 9.5rem);
line-height: 1;
transition: color .5s ease;
}
.temp-deg {
font-size: clamp(1.8rem, 7vw, 3.2rem);
margin-top: .15em;
margin-left: .05em;
opacity: .6;
transition: color .5s ease;
}
.hero-sub {
margin-top: .45rem;
font-size: .95rem;
color: var(--muted);
}
.condition-pill {
display: inline-flex;
align-items: center;
gap: .35rem;
margin-top: .85rem;
padding: .3rem 1rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 999px;
font-size: .82rem;
font-weight: 500;
color: var(--muted);
}
/* ── Content ─────────────────────────────────────────────── */
.content {
max-width: 680px;
margin: 0 auto;
padding: 1.5rem 1rem 3.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-label {
font-size: .62rem;
font-weight: 600;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .6rem;
}
/* ── Metric grid ─────────────────────────────────────────── */
.row3 {
display: grid;
grid-template-columns: repeat(3,1fr);
gap: .7rem;
}
.row4 {
display: grid;
grid-template-columns: repeat(2,1fr);
gap: .7rem;
}
@media (min-width: 480px) {
.row4 { grid-template-columns: repeat(4,1fr); }
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.1rem 0.9rem;
}
.card-label {
font-size: .6rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .3rem;
}
.card-val {
font-family: 'Barlow Condensed', sans-serif;
font-size: 2rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.card-unit {
font-size: .68rem;
color: var(--muted);
margin-top: .2rem;
}
/* ── Wind card ───────────────────────────────────────────── */
.wind-wrap {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.wind-compass-wrap { flex-shrink: 0; }
canvas { display: block; }
.wind-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: .75rem 1.25rem;
}
.wind-stat-label {
font-size: .6rem;
font-weight: 600;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .2rem;
}
.wind-stat-val {
font-family: 'Barlow Condensed', sans-serif;
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
}
.wind-stat-unit {
font-size: .65rem;
color: var(--muted);
}
@media (max-width: 460px) {
.wind-wrap { flex-direction: column; }
}
/* ── Footer ──────────────────────────────────────────────── */
.foot {
text-align: center;
font-size: .7rem;
color: var(--muted);
padding-bottom: 1.5rem;
}
</style>
</head>
<body>
<header class="hdr">
<div class="hdr-left">
<a href="/" class="hdr-back" aria-label="Back to app">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
App
</a>
<span class="hdr-sep">·</span>
<span class="hdr-station" id="stationName">Genesis 8:22</span>
</div>
<div class="hdr-live"><span class="live-dot"></span>Live</div>
<div class="hdr-right">
<a href="/dashboard" class="hdr-dash-link">Dashboard <span class="arr">→</span></a>
<div class="hdr-time" id="hdrTime"></div>
</div>
</header>
<section class="hero">
<div class="hero-dots"></div>
<div class="hero-sweep"></div>
<div class="hero-ring"></div>
<div class="hero-ring2"></div>
<div class="hero-body">
<div class="temp-row">
<div class="temp-num" id="tempNum">—</div>
<div class="temp-deg" id="tempDeg"></div>
</div>
<div class="hero-sub" id="heroSub"></div>
<div><span class="condition-pill" id="condPill">Loading…</span></div>
</div>
</section>
<main class="content">
<div>
<div class="section-label">Atmosphere</div>
<div class="row3">
<div class="card">
<div class="card-label">Humidity</div>
<div class="card-val" id="vHumidity">—</div>
<div class="card-unit">%</div>
</div>
<div class="card">
<div class="card-label">Dew Point</div>
<div class="card-val" id="vDew">—</div>
<div class="card-unit">°F</div>
</div>
<div class="card">
<div class="card-label">Pressure</div>
<div class="card-val" id="vPressure">—</div>
<div class="card-unit">inHg</div>
</div>
</div>
</div>
<div>
<div class="section-label">Wind</div>
<div class="wind-wrap">
<div class="wind-compass-wrap">
<canvas id="compass" width="152" height="152" style="width:152px;height:152px"></canvas>
</div>
<div class="wind-grid">
<div>
<div class="wind-stat-label">Speed</div>
<div class="wind-stat-val" id="vWindSpeed">—</div>
<div class="wind-stat-unit">mph</div>
</div>
<div>
<div class="wind-stat-label">Gust</div>
<div class="wind-stat-val" id="vWindGust">—</div>
<div class="wind-stat-unit">mph</div>
</div>
<div>
<div class="wind-stat-label">From</div>
<div class="wind-stat-val" id="vWindDir">—</div>
<div class="wind-stat-unit" id="vWindDeg"></div>
</div>
<div>
<div class="wind-stat-label">Max Today</div>
<div class="wind-stat-val" id="vWindMax">—</div>
<div class="wind-stat-unit">mph</div>
</div>
</div>
</div>
</div>
<div>
<div class="section-label">Precipitation &amp; Solar</div>
<div class="row4">
<div class="card">
<div class="card-label">Rain Today</div>
<div class="card-val" id="vRainDay">—</div>
<div class="card-unit">inches</div>
</div>
<div class="card">
<div class="card-label">Rain Rate</div>
<div class="card-val" id="vRainRate">—</div>
<div class="card-unit">in / hr</div>
</div>
<div class="card">
<div class="card-label">UV Index</div>
<div class="card-val" id="vUV">—</div>
<div class="card-unit" id="vUVLabel"></div>
</div>
<div class="card">
<div class="card-label">Solar Rad.</div>
<div class="card-val" id="vSolar">—</div>
<div class="card-unit">W/m²</div>
</div>
</div>
</div>
</main>
<div class="foot" id="foot">Refreshes automatically every 2 minutes</div>
<script>
// ── Utilities ────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
const fmt = (v, d = 1) => v != null ? (+v).toFixed(d) : '—';
function cardinal(deg) {
if (deg == null) return '—';
return ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][
Math.round(+deg / 22.5) % 16
];
}
function tempColor(f) {
if (f == null) return 'var(--text)';
if (f <= 32) return '#93c5fd';
if (f <= 50) return '#60a5fa';
if (f <= 65) return 'var(--text)';
if (f <= 80) return '#f59e0b';
if (f <= 90) return '#f97316';
return '#ef4444';
}
function uvInfo(uv) {
if (uv == null) return ['—', 'var(--text)'];
if (uv < 3) return ['Low', '#4ade80'];
if (uv < 6) return ['Moderate', '#facc15'];
if (uv < 8) return ['High', '#f97316'];
if (uv < 11) return ['Very High', '#ef4444'];
return ['Extreme', '#d946ef'];
}
function conditionText(d) {
if (!d) return '—';
if (d.rainratein > 0.05) return '🌧 Raining';
const s = d.solarradiation;
if (s != null) {
if (s > 700) return '☀️ Sunny';
if (s > 250) return '🌤 Partly Cloudy';
if (s > 60) return '⛅ Mostly Cloudy';
if (s > 5) return '☁️ Overcast';
}
return '🌙 Night';
}
// ── Compass ──────────────────────────────────────────────────────────────────
function drawCompass(dir) {
const canvas = $('compass');
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssSize = 152;
canvas.width = cssSize * dpr;
canvas.height = cssSize * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cx = cssSize / 2, cy = cssSize / 2, r = cssSize / 2 - 7;
const dark = document.documentElement.dataset.theme === 'dark' ||
(!document.documentElement.dataset.theme &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
const cCard = dark ? '#101c29' : '#f4f7fb';
const cRing = dark ? '#192e44' : '#ccd6e2';
const cLabel = dark ? '#496070' : '#8fa0b8';
const cAccent = dark ? '#60a5fa' : '#2563eb';
const cNorth = dark ? '#f87171' : '#dc2626';
// Face
ctx.fillStyle = cCard;
ctx.beginPath();
ctx.arc(cx, cy, r + 5, 0, Math.PI * 2);
ctx.fill();
// Concentric rings
[1/3, 2/3, 1].forEach(f => {
ctx.beginPath();
ctx.arc(cx, cy, r * f, 0, Math.PI * 2);
ctx.strokeStyle = cRing;
ctx.lineWidth = .7;
ctx.stroke();
});
// Ticks (major at cardinals, minor at intercardinals)
for (let i = 0; i < 8; i++) {
const a = (i * 45 - 90) * Math.PI / 180;
const isMajor = i % 2 === 0;
ctx.beginPath();
ctx.moveTo(cx + r * Math.cos(a), cy + r * Math.sin(a));
ctx.lineTo(cx + (r - (isMajor ? 9 : 5)) * Math.cos(a), cy + (r - (isMajor ? 9 : 5)) * Math.sin(a));
ctx.strokeStyle = isMajor ? cLabel : cRing;
ctx.lineWidth = isMajor ? 1.5 : .7;
ctx.stroke();
}
// Cardinal labels
const labelR = r - 18;
const cards = [['N', -90, cNorth], ['E', 0, cLabel], ['S', 90, cLabel], ['W', 180, cLabel]];
cards.forEach(([lbl, deg, color]) => {
const a = deg * Math.PI / 180;
ctx.font = `700 ${lbl === 'N' ? 11 : 10}px 'Barlow', sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.fillText(lbl, cx + labelR * Math.cos(a), cy + labelR * Math.sin(a));
});
// Wind arrow (points FROM the origin direction)
if (dir != null) {
// winddir: degrees from north, clockwise, = where wind comes FROM
// Arrow tip at the "from" direction, tail at center
const a = ((+dir - 90) * Math.PI) / 180;
const tipR = r * 0.60;
const tx = cx + tipR * Math.cos(a);
const ty = cy + tipR * Math.sin(a);
// Shaft
ctx.strokeStyle = cAccent;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(tx, ty);
ctx.stroke();
// Arrowhead
const ha = Math.atan2(ty - cy, tx - cx);
const hl = 13;
ctx.fillStyle = cAccent;
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx - hl * Math.cos(ha - 0.38), ty - hl * Math.sin(ha - 0.38));
ctx.lineTo(tx - hl * 0.55 * Math.cos(ha), ty - hl * 0.55 * Math.sin(ha));
ctx.lineTo(tx - hl * Math.cos(ha + 0.38), ty - hl * Math.sin(ha + 0.38));
ctx.closePath();
ctx.fill();
// Tail stub
const tailA = a + Math.PI;
ctx.strokeStyle = cAccent;
ctx.globalAlpha = .3;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + r * 0.22 * Math.cos(tailA), cy + r * 0.22 * Math.sin(tailA));
ctx.stroke();
ctx.globalAlpha = 1;
// Center pip
ctx.fillStyle = cAccent;
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
}
}
// ── Fetch & render ───────────────────────────────────────────────────────────
async function load() {
try {
const r = await fetch('/api/conditions/public');
if (!r.ok) throw new Error('not ok');
const d = await r.json();
if (!d) {
$('condPill').textContent = 'No data yet';
return;
}
// Station name (server may include it)
if (d.stationName) {
const nameEl = $('stationName');
if (nameEl) nameEl.textContent = d.stationName;
}
// Header
if (d.dateutc) {
const dt = new Date(d.dateutc);
$('hdrTime').textContent = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
$('foot').textContent = `Updated ${dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · refreshes automatically`;
}
// Hero temperature
const tempEl = $('tempNum');
const degEl = $('tempDeg');
const tv = d.tempf != null ? +d.tempf : null;
tempEl.textContent = tv != null ? Math.round(tv) : '—';
degEl.textContent = tv != null ? '°F' : '';
const tc = tempColor(tv);
tempEl.style.color = tc;
degEl.style.color = tc;
// Sub line
const fl = d.feelsLike ?? d.tempf;
$('heroSub').textContent = fl != null ? `Feels like ${Math.round(+fl)}°F` : '';
// Condition pill
$('condPill').textContent = conditionText(d);
// Atmosphere
set('vHumidity', fmt(d.humidity, 0));
set('vDew', fmt(d.dewPoint));
set('vPressure', d.baromrelin != null ? fmt(d.baromrelin, 2) : '—');
// Wind
set('vWindSpeed', fmt(d.windspeedmph, 0));
set('vWindGust', fmt(d.windgustmph, 0));
set('vWindDir', cardinal(d.winddir));
set('vWindDeg', d.winddir != null ? Math.round(+d.winddir) + '°' : '');
set('vWindMax', fmt(d.maxdailygust, 0));
drawCompass(d.winddir);
// Precip + solar
set('vRainDay', fmt(d.dailyrainin, 2));
set('vRainRate', fmt(d.rainratein, 2));
const [uvLabel, uvColor] = uvInfo(d.uv);
const uvEl = $('vUV');
uvEl.textContent = d.uv != null ? fmt(d.uv, 0) : '—';
uvEl.style.color = uvColor;
set('vUVLabel', d.uv != null ? uvLabel : '');
set('vSolar', fmt(d.solarradiation, 0));
} catch {
$('condPill').textContent = 'Station offline';
}
}
// Draw compass face immediately on load; redrawn with arrow when data arrives
drawCompass(null);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => drawCompass(null));
window.addEventListener('resize', () => drawCompass(null));
load();
</script>
</body>
</html>
+200
View File
@@ -0,0 +1,200 @@
<!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="#070c14">
<link rel="manifest" href="/dashboard.webmanifest">
<title>Account</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #0d1b2a; --ink-light: #5a7089; --paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
--border: #ccd6e2; --accent: #2563eb; --cool: #0e7490; --ok: #2f8a4a;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root { --ink: #dce6f0; --ink-light: #8aa2b8; --paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29; --border: #22405c; --accent: #60a5fa; --cool: #38bdf8; --ok: #5cc27a; }
}
html { -webkit-text-size-adjust: 100%; }
html, body { min-height: 100vh; min-height: 100dvh; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; flex-wrap: wrap; padding: calc(1rem + env(safe-area-inset-top)) calc(1.5rem + env(safe-area-inset-right)) 1rem calc(1.5rem + env(safe-area-inset-left)); border-bottom: 1px solid var(--border); background: var(--paper-dark); }
.header h1 { font-size: 1.15rem; }
.header .sub { font-size: 0.8rem; color: var(--ink-light); }
.header-right { display: flex; gap: 0.6rem; }
.nav-link { color: var(--ink-light); text-decoration: none; font-size: 0.8rem; font-weight: 600; padding: 0.4rem 0.8rem; min-height: 38px; display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 8px; }
.nav-link:hover { border-color: var(--accent); color: var(--accent); }
.page { max-width: 860px; margin: 0 auto; padding: 1.5rem calc(1rem + env(safe-area-inset-right)) calc(4rem + env(safe-area-inset-bottom)) calc(1rem + env(safe-area-inset-left)); }
.section { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 14px; padding: 1.4rem; margin-bottom: 1.25rem; }
.section-hdr { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.9rem; margin-bottom: 0.9rem; }
label { display: block; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.3rem; }
input, select { width: 100%; padding: 0.6rem 0.75rem; min-height: 44px; font: inherit; font-size: 16px; color: var(--ink); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; }
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
button { font: inherit; font-size: 0.85rem; font-weight: 600; min-height: 44px; padding: 0 1.1rem; border-radius: 8px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); cursor: pointer; touch-action: manipulation; }
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { color: var(--accent); }
button.small { min-height: 36px; padding: 0 0.7rem; font-size: 0.78rem; }
.msg { font-size: 0.85rem; margin-left: 0.7rem; color: var(--ok); }
.msg.err { color: var(--accent); }
.hint { font-size: 0.78rem; color: var(--ink-light); line-height: 1.5; margin-top: 0.6rem; }
.badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 99px; font-size: 0.7rem; font-weight: 700; border: 1px solid var(--border); background: var(--paper); text-transform: uppercase; letter-spacing: 0.04em; }
.badge.admin { color: var(--accent); border-color: var(--accent); }
.badge.viewer { color: var(--cool); border-color: var(--cool); }
.badge.off { color: var(--ink-light); }
.users { display: grid; gap: 0.7rem; }
.user { background: var(--paper); border: 1px solid var(--border); border-radius: 12px; padding: 0.8rem 0.95rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; }
.user.disabled { opacity: 0.6; }
.user .who { flex: 1 1 180px; min-width: 0; }
.user .who b { display: block; overflow-wrap: anywhere; }
.user .who span { font-size: 0.74rem; color: var(--ink-light); }
.user .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
dialog { border: 1px solid var(--border); border-radius: 14px; background: var(--paper-dark); color: var(--ink); padding: 1.3rem; width: min(380px, 92vw); margin: auto; }
dialog::backdrop { background: rgba(0,0,0,0.55); }
dialog h3 { font-size: 1rem; margin-bottom: 0.8rem; }
dialog .row { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="header">
<div><h1>Account</h1><div class="sub" id="whoami">Loading…</div></div>
<div class="header-right">
<a href="/dashboard" class="nav-link">← Dashboard</a>
<a href="/dashboard/logout" class="nav-link">Sign out</a>
</div>
</div>
<div class="page">
<div class="section">
<div class="section-hdr">Change my password</div>
<div class="grid">
<div><label for="pwCurrent">Current password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
<div><label for="pwNew">New password (min 8 characters)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
</div>
<button class="primary" id="pwBtn">Change password</button><span class="msg" id="pwMsg"></span>
<p class="hint">Changing your password signs you out everywhere else.</p>
</div>
<div id="adminArea" class="hidden">
<div class="section">
<div class="section-hdr"><span>Users</span><span id="userCount" class="hint" style="margin:0"></span></div>
<div class="users" id="userList"></div>
</div>
<div class="section">
<div class="section-hdr">Add a user</div>
<div class="grid">
<div><label for="nuName">Username</label><input id="nuName" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></div>
<div><label for="nuPass">Temporary password (min 8)</label><input id="nuPass" type="password" autocomplete="new-password"></div>
<div><label for="nuRole">Role</label>
<select id="nuRole"><option value="viewer">Viewer — read-only dashboard</option><option value="admin">Admin — full access + users</option></select></div>
</div>
<button class="primary" id="nuBtn">Create user</button><span class="msg" id="nuMsg"></span>
<p class="hint"><b>Viewers</b> can see all station data, records and charts, and change their own password. <b>Admins</b> can also change station settings, import data, and manage users. The last active admin can't be removed, demoted or disabled.</p>
</div>
</div>
</div>
<dialog id="dlg"><h3 id="dlgTitle"></h3><div id="dlgBody"></div><div class="row"><button id="dlgCancel">Cancel</button><button class="primary" id="dlgOk">OK</button></div></dialog>
<script>
const $ = (id) => document.getElementById(id);
let me = null;
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const when = (ts) => (ts ? new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : 'never');
async function api(url, method = 'GET', body) {
const r = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
if (r.status === 401) { location.href = '/dashboard/login'; throw new Error('Signed out'); }
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Request failed');
return data;
}
function flash(el, text, isErr) { el.textContent = text; el.className = 'msg' + (isErr ? ' err' : ''); setTimeout(() => { el.textContent = ''; }, 5000); }
// Small promise-based dialog (works on iOS where prompt()/confirm() are awkward)
function ask(title, bodyHtml, okLabel = 'OK') {
return new Promise((resolve) => {
const dlg = $('dlg');
$('dlgTitle').textContent = title; $('dlgBody').innerHTML = bodyHtml; $('dlgOk').textContent = okLabel;
const done = (v) => { dlg.close(); resolve(v); };
$('dlgOk').onclick = () => done(dlg.querySelector('input') ? dlg.querySelector('input').value : true);
$('dlgCancel').onclick = () => done(null);
dlg.oncancel = () => resolve(null);
dlg.showModal();
dlg.querySelector('input')?.focus();
});
}
$('pwBtn').addEventListener('click', async () => {
const cur = $('pwCurrent').value, nw = $('pwNew').value;
if (!cur || nw.length < 8) return flash($('pwMsg'), 'Enter your current password and a new one (min 8 characters)', true);
try {
await api('/api/dashboard/change-password', 'POST', { currentPassword: cur, newPassword: nw });
$('pwCurrent').value = ''; $('pwNew').value = '';
flash($('pwMsg'), 'Password changed');
} catch (e) { flash($('pwMsg'), e.message, true); }
});
async function loadUsers() {
const users = await api('/api/users');
$('userCount').textContent = `${users.length} user${users.length === 1 ? '' : 's'}`;
$('userList').innerHTML = users.map((u) => {
const self = u.username === me.username;
return `<div class="user${u.disabled ? ' disabled' : ''}" data-u="${esc(u.username)}">
<div class="who"><b>${esc(u.username)}${self ? ' (you)' : ''}</b><span>Added ${when(u.createdAt)} · last sign-in ${when(u.lastLogin)}</span></div>
<div><span class="badge ${u.role}">${u.role}</span> ${u.disabled ? '<span class="badge off">disabled</span>' : ''}</div>
<div class="actions">
<button class="small" data-act="role">${u.role === 'admin' ? 'Make viewer' : 'Make admin'}</button>
<button class="small" data-act="reset">Reset password</button>
<button class="small" data-act="toggle">${u.disabled ? 'Enable' : 'Disable'}</button>
<button class="small danger" data-act="delete">Delete</button>
</div></div>`;
}).join('');
$('userList').querySelectorAll('.user').forEach((row) => {
const name = row.dataset.u, u = users.find((x) => x.username === name);
row.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', () => act(b.dataset.act, u)));
});
}
async function act(kind, u) {
try {
if (kind === 'role') {
const next = u.role === 'admin' ? 'viewer' : 'admin';
if (!(await ask(`Make ${u.username} a ${next}?`, `<p class="hint">${next === 'admin' ? 'Admins can change settings and manage users.' : 'Viewers have read-only access to the dashboard.'}</p>`, 'Confirm'))) return;
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { role: next });
} else if (kind === 'reset') {
const pw = await ask(`Reset password for ${u.username}`, '<label for="rp">New password (min 8)</label><input id="rp" type="password" autocomplete="new-password">', 'Reset');
if (pw == null) return;
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { password: pw });
} else if (kind === 'toggle') {
await api('/api/users/' + encodeURIComponent(u.username), 'PATCH', { disabled: !u.disabled });
} else if (kind === 'delete') {
if (!(await ask(`Delete ${u.username}?`, '<p class="hint">They will lose access immediately. This can\'t be undone.</p>', 'Delete'))) return;
await api('/api/users/' + encodeURIComponent(u.username), 'DELETE');
}
await loadUsers();
} catch (e) { await ask('Could not do that', `<p class="hint">${esc(e.message)}</p>`, 'OK'); }
}
$('nuBtn').addEventListener('click', async () => {
try {
await api('/api/users', 'POST', { username: $('nuName').value.trim(), password: $('nuPass').value, role: $('nuRole').value });
$('nuName').value = ''; $('nuPass').value = '';
flash($('nuMsg'), 'User created');
await loadUsers();
} catch (e) { flash($('nuMsg'), e.message, true); }
});
(async () => {
try {
me = await api('/api/dashboard/me');
$('whoami').innerHTML = `Signed in as <b>${esc(me.username)}</b> · ${me.role}`;
if (me.role === 'admin') { $('adminArea').classList.remove('hidden'); await loadUsers(); }
} catch {}
})();
</script>
</body>
</html>
+12 -5
View File
@@ -2,18 +2,24 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#070c14">
<meta name="apple-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 — Sign In</title> <title>Station Dashboard — Sign In</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--ink: #1a1a1a; --paper: #f5f2ec; --paper-dark: #ece7db; --ink: #0d1b2a; --paper: #edf2f7; --paper-dark: #ffffff;
--border: #b9ae9b; --accent: #c25a2a; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif; --border: #ccd6e2; --accent: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { --ink: #ece7dc; --paper: #191510; --paper-dark: #201c16; --border: #3a342a; --accent: #e07848; } :root { --ink: #dce6f0; --paper: #070c14; --paper-dark: #0d1720; --border: #22405c; --accent: #60a5fa; }
} }
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; } html, body { min-height: 100vh; min-height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; }
.card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(380px, 94vw); } .card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(380px, 94vw); }
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; } h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
.sub { color: var(--border); font-size: 0.85rem; margin-bottom: 2rem; } .sub { color: var(--border); font-size: 0.85rem; margin-bottom: 2rem; }
@@ -42,6 +48,7 @@ button:hover { opacity: 0.88; }
</div> </div>
<script> <script>
if (location.search.includes('error=1')) document.getElementById('err').style.display = 'block'; if (location.search.includes('error=1')) document.getElementById('err').style.display = 'block';
if (location.search.includes('error=2')) { const e = document.getElementById('err'); e.textContent = 'Too many failed attempts. Try again in a few minutes.'; e.style.display = 'block'; }
if (location.search.includes('recovered=1')) document.getElementById('ok').style.display = 'block'; if (location.search.includes('recovered=1')) document.getElementById('ok').style.display = 'block';
</script> </script>
</body> </body>
+5 -5
View File
@@ -7,11 +7,11 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--ink: #1a1a1a; --paper: #f5f2ec; --paper-dark: #ece7db; --ink: #0d1b2a; --paper: #edf2f7; --paper-dark: #ffffff;
--border: #b9ae9b; --accent: #c25a2a; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif; --border: #ccd6e2; --accent: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { --ink: #ece7dc; --paper: #191510; --paper-dark: #201c16; --border: #3a342a; --accent: #e07848; } :root { --ink: #dce6f0; --paper: #070c14; --paper-dark: #0d1720; --border: #22405c; --accent: #60a5fa; }
} }
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; } html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; }
.card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(400px, 94vw); } .card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(400px, 94vw); }
@@ -38,12 +38,12 @@ button:hover { opacity: 0.88; }
<code>Dashboard recovery token:</code><br> <code>Dashboard recovery token:</code><br>
In CasaOS, open the app's log from the container details panel. The token changes daily. In CasaOS, open the app's log from the container details panel. The token changes daily.
</div> </div>
<div id="err" class="error" style="display:none">Invalid token or password too short (min 6 chars).</div> <div id="err" class="error" style="display:none">Invalid token or password too short (min 8 chars). This resets the primary admin account.</div>
<form method="POST" action="/dashboard/recover"> <form method="POST" action="/dashboard/recover">
<label>Recovery Token</label> <label>Recovery Token</label>
<input name="token" type="text" autocomplete="off" spellcheck="false" autofocus required placeholder="Paste token from server logs"> <input name="token" type="text" autocomplete="off" spellcheck="false" autofocus required placeholder="Paste token from server logs">
<label>New Password</label> <label>New Password</label>
<input name="password" type="password" autocomplete="new-password" required minlength="6"> <input name="password" type="password" autocomplete="new-password" required minlength="8">
<button type="submit">Set New Password</button> <button type="submit">Set New Password</button>
</form> </form>
<a href="/dashboard/login" class="back">← Back to sign in</a> <a href="/dashboard/login" class="back">← Back to sign in</a>
+13 -39
View File
@@ -7,16 +7,16 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649; --ink: #0d1b2a; --ink-mid: #2b3f55; --ink-light: #5a7089;
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7; --paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a; --border: #ccd6e2; --accent: #2563eb; --cool: #0e7490;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif; --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399; --ink: #dce6f0; --ink-mid: #b7c6d6; --ink-light: #8aa2b8;
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620; --paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29;
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8; --border: #22405c; --accent: #60a5fa; --cool: #38bdf8;
} }
} }
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; } html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
@@ -88,6 +88,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
<div><h1>Settings</h1></div> <div><h1>Settings</h1></div>
<div class="header-right"> <div class="header-right">
<a href="/dashboard" class="nav-link">← Dashboard</a> <a href="/dashboard" class="nav-link">← Dashboard</a>
<a href="/dashboard/account" class="nav-link">Account</a>
<a href="/dashboard/logout" class="nav-link">Sign out</a> <a href="/dashboard/logout" class="nav-link">Sign out</a>
</div> </div>
</div> </div>
@@ -135,7 +136,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
<div class="section"> <div class="section">
<div class="section-hdr">Station Display</div> <div class="section-hdr">Station Display</div>
<div> <div>
<label>Station Name (shown on public conditions page)</label> <label>Station Name (shown on the Station tab of the weather app)</label>
<input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off"> <input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off">
</div> </div>
<div class="settings-actions"> <div class="settings-actions">
@@ -204,17 +205,12 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
</div> </div>
</div> </div>
<!-- Change Password --> <!-- Account -->
<div class="section"> <div class="section">
<div class="section-hdr">Change Password</div> <div class="section-hdr">Account &amp; Users</div>
<div class="settings-grid"> <p class="hint" style="margin:0 0 0.85rem">Change your password and manage who can sign in (admin and viewer accounts).</p>
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div> <a class="export-link" href="/dashboard/account">Open account &amp; users →</a>
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div> </div>
</div>
<div class="settings-actions">
<button class="primary" id="changePwBtn">Change Password</button>
<span id="pwMsg" class="save-msg"></span>
</div>
</div> </div>
</div> </div>
@@ -413,28 +409,6 @@ async function pollBackfill() {
document.getElementById('backfillBtn').addEventListener('click', startBackfill); document.getElementById('backfillBtn').addEventListener('click', startBackfill);
// ── Change Password ───────────────────────────────────────────────────────────
document.getElementById('changePwBtn').addEventListener('click', async () => {
const cur = document.getElementById('pwCurrent')?.value;
const nw = document.getElementById('pwNew')?.value;
const msg = document.getElementById('pwMsg');
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: cur, newPassword: nw })
});
if (r.ok) {
msg.textContent = 'Password changed'; msg.className = 'save-msg';
document.getElementById('pwCurrent').value = '';
document.getElementById('pwNew').value = '';
} else {
const e = await r.json();
msg.textContent = e.error || 'Error'; msg.className = 'save-msg err';
}
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Scroll shadow ───────────────────────────────────────────────────────────── // ── Scroll shadow ─────────────────────────────────────────────────────────────
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
+6 -6
View File
@@ -7,11 +7,11 @@
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--ink: #1a1a1a; --paper: #f5f2ec; --paper-dark: #ece7db; --ink: #0d1b2a; --paper: #edf2f7; --paper-dark: #ffffff;
--border: #b9ae9b; --accent: #c25a2a; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif; --border: #ccd6e2; --accent: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { --ink: #ece7dc; --paper: #191510; --paper-dark: #201c16; --border: #3a342a; --accent: #e07848; } :root { --ink: #dce6f0; --paper: #070c14; --paper-dark: #0d1720; --border: #22405c; --accent: #60a5fa; }
} }
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; } html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); display: flex; align-items: center; justify-content: center; }
.card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(380px, 94vw); } .card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(380px, 94vw); }
@@ -30,12 +30,12 @@ button:hover { opacity: 0.88; }
<div class="card"> <div class="card">
<h1>Create Dashboard Account</h1> <h1>Create Dashboard Account</h1>
<p class="sub">First-time setup — create your admin account</p> <p class="sub">First-time setup — create your admin account</p>
<div id="err" class="error" style="display:none">Password must be at least 6 characters.</div> <div id="err" class="error" style="display:none">Username must be 3–32 letters, numbers, . _ - and the password at least 8 characters.</div>
<form method="POST" action="/dashboard/setup"> <form method="POST" action="/dashboard/setup">
<label>Username</label> <label>Username</label>
<input name="username" type="text" autocomplete="username" autofocus required> <input name="username" pattern="[A-Za-z0-9._\-]{3,32}" autocapitalize="none" type="text" autocomplete="username" autofocus required>
<label>Password</label> <label>Password</label>
<input name="password" type="password" autocomplete="new-password" required minlength="6"> <input name="password" type="password" autocomplete="new-password" required minlength="8">
<button type="submit">Create Account</button> <button type="submit">Create Account</button>
</form> </form>
<p class="note">This account protects your station dashboard. Store these credentials somewhere safe.</p> <p class="note">This account protects your station dashboard. Store these credentials somewhere safe.</p>
+117
View File
@@ -0,0 +1,117 @@
/* Dashboard UI layer: tabs, tiles, hero extras, cards, calendars */
:root { --header-h: 64px; --tabbar-h: 54px; --radius: 14px; }
/* ── Tabs ── */
.tabbar { position: sticky; top: var(--header-h); z-index: 9; display: flex; gap: 0.25rem; padding: 0.4rem calc(1rem + env(safe-area-inset-left)) 0.4rem calc(1rem + env(safe-area-inset-right));
background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; }
.tabbar::-webkit-scrollbar { display: none; }
.tab { flex: 1 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; min-height: 40px; padding: 0 1rem; border: 1px solid transparent; border-radius: 999px;
background: transparent; color: var(--ink-light); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: background .18s, color .18s, border-color .18s; touch-action: manipulation; }
.tab:hover { color: var(--ink); background: var(--paper-dark); }
.tab.active { background: var(--accent); color: #fff; }
.tab-ico { font-size: 0.95rem; line-height: 1; }
.tab-pane { display: none; animation: pane-in .28s cubic-bezier(.2,0,0,1); }
.tab-pane.active { display: block; }
@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 640px) {
/* Thumb-reach bottom navigation, safe-area aware */
.tabbar { position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 20; border-top: 1px solid var(--border); border-bottom: none; justify-content: space-between;
padding: 0.35rem calc(0.5rem + env(safe-area-inset-right)) calc(0.35rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-left)); overflow: visible; background: color-mix(in srgb, var(--paper-dark) 92%, transparent); }
.tab { flex: 1 1 0; flex-direction: column; gap: 0.1rem; min-height: 50px; padding: 0 0.2rem; border-radius: 12px; font-size: 0.68rem; }
.tab-ico { font-size: 1.15rem; }
.page { padding-bottom: calc(5.5rem + env(safe-area-inset-bottom)) !important; }
}
/* ── Unified tile language ── */
.section-card, .chart-card, .stat, .today-bar, .settings-panel, .ui-card { border-radius: var(--radius); }
.stat { position: relative; overflow: hidden; }
.delta { font-size: 0.72rem; margin-top: 0.3rem; color: var(--ink-light); font-variant-numeric: tabular-nums; white-space: nowrap; }
.delta.up { color: var(--accent); } .delta.down { color: var(--cool); } .delta.flat { color: var(--ink-light); }
.hero-stat .delta, .hero-grid > div .delta { margin-top: 0.15rem; }
.hero-grid > div { background: var(--paper); border: 1px solid var(--border); border-radius: 12px; padding: 0.65rem 0.8rem; }
/* ── Hero extras ── */
.hero-main { position: relative; isolation: isolate; }
.hero-main { padding-bottom: 3.4rem; }
.hero-spark { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 44px; opacity: 0.8; pointer-events: none; border-radius: 8px; }
.hero-hilo { display: flex; gap: 0.9rem; margin-top: 0.55rem; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.hero-hilo .hi { color: var(--accent); font-weight: 700; } .hero-hilo .lo { color: var(--cool); font-weight: 700; }
/* ── Highlights ── */
.highlights { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.highlights:empty { display: none; }
.chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.8rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600;
background: color-mix(in srgb, var(--accent) 12%, var(--paper-dark)); border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); }
.chip.cool { background: color-mix(in srgb, var(--cool) 12%, var(--paper-dark)); border-color: color-mix(in srgb, var(--cool) 45%, var(--border)); }
/* ── Cards & grids ── */
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
.two-col:empty { display: none; }
@media (max-width: 720px) { .two-col { grid-template-columns: 1fr; } }
.ui-card { background: var(--paper-dark); border: 1px solid var(--border); padding: 1.1rem 1.2rem; }
.ui-card h3 { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 0.9rem; }
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 0.7rem 1rem; }
.kv b { display: block; font-size: 1.15rem; line-height: 1.2; font-variant-numeric: tabular-nums; }
.kv span { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); }
.kv small { display: block; font-size: 0.72rem; color: var(--ink-light); }
.sun-arc { width: 100%; height: 86px; display: block; margin-bottom: 0.7rem; }
.bar-meter { height: 8px; border-radius: 99px; background: var(--border); overflow: hidden; margin-top: 0.35rem; }
.bar-meter > i { display: block; height: 100%; background: var(--cool); border-radius: 99px; }
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 700; background: var(--paper); border: 1px solid var(--border); }
.pill.ok { color: #3ca050; border-color: #3ca05088; } .pill.warn { color: var(--accent); border-color: var(--accent); }
.insight-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.cmp-row { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
.cmp-row:last-child { border-bottom: none; }
.cmp-row b { font-variant-numeric: tabular-nums; }
.note { font-size: 0.78rem; color: var(--ink-light); line-height: 1.45; }
.roses { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.8rem; text-align: center; }
.roses canvas { width: 100%; max-width: 170px; height: auto; aspect-ratio: 1; margin: 0 auto; display: block; }
.roses small { display: block; font-size: 0.72rem; color: var(--ink-light); margin-top: 0.2rem; }
.chart-card .chart-title { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.chart-reset { font: inherit; font-size: 0.68rem; text-transform: none; letter-spacing: 0; padding: 0.2rem 0.6rem; border-radius: 99px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); cursor: pointer; min-height: 28px; }
/* ── Wind compass ── */
.wind-wrap { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.wind-wrap .stats { flex: 1 1 280px; }
#windCompass { flex: 0 0 auto; width: 116px; height: 116px; margin: 0 auto; }
/* ── Calendar ── */
.cal-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.8rem; }
.cal-months { display: grid; grid-auto-flow: column; grid-auto-columns: 14px; gap: 3px; font-size: 0.62rem; color: var(--ink-light); height: 1rem; }
.heatmap-cell { cursor: default; }
.heatmap-wrap { scrollbar-width: thin; }
.heatmap-cell.today { outline: 2px solid var(--ink); outline-offset: -1px; }
@media (max-width: 640px) {
.hero-spark { height: 40px; }
.hero-grid > div { padding: 0.55rem 0.65rem; }
.ui-card { padding: 0.9rem; }
}
@media (prefers-reduced-motion: reduce) { .tab-pane { animation: none; } }
/* ── Phone fit: never let canvases force the grid wider than the screen ── */
canvas.sparkline { max-width: 100%; }
.hero-grid > div, .stat, .ui-card, .chart-card { min-width: 0; }
@media (max-width: 640px) {
.hero-main { width: 100%; }
.hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 0.6rem !important; }
.hero-grid > div { padding: 0.55rem 0.65rem; }
.stats.secondary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.wind-wrap { justify-content: center; }
.wind-wrap .stats { flex-basis: 100%; }
#windCompass { width: 132px; height: 132px; }
.section-card { padding-left: 1rem; padding-right: 1rem; }
}
@media (max-width: 640px) { .header { position: static; } }
/* Panels are always expanded now */
.settings-panel.always-open > summary { cursor: default; pointer-events: none; }
.settings-panel.always-open > summary::before { display: none; }
.settings-panel.always-open { margin-top: 1rem !important; }
/* ── Unified slate/blue chrome (data colours stay orange = warm, blue = cool) ── */
.tab.active, .range-btn.active, .always-open { --chrome: var(--ui); }
.tab.active, .range-btn.active { background: var(--ui) !important; border-color: var(--ui) !important; color: #fff; }
.tab:hover, .logout:hover { border-color: var(--ui); color: var(--ui); }
.chart-reset:hover { border-color: var(--ui); }
a { color: var(--ui); }
+600
View File
@@ -0,0 +1,600 @@
/* Dashboard UI layer — builds on the globals from dashboard.html (charts, makeChart, chartColors, …) */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const fmt = (v, d = 1) => (num(v) == null ? '—' : v.toFixed(d));
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
const sum = (a) => a.reduce((x, y) => x + y, 0);
const MS_DAY = 86400000;
let DAILY = []; // per-day summaries from /api/station/daily (station-local days)
let RECENT = []; // raw readings, last 48h
let LATEST = null;
let LOC = null;
let TZ = 'America/New_York';
let rainRange = 90;
let calMetric = 'hi';
const rendered = {}; // which lazy tabs have been drawn for the current data
async function getJSON(url) {
const r = await fetch(url);
if (r.status === 401) { location.href = '/dashboard/login'; throw new Error('auth'); }
if (!r.ok) throw new Error(url);
return r.json();
}
// ── Date helpers (station-local day strings, YYYY-MM-DD) ─────────────────────
function dayStr(ts, tz = TZ) {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(new Date(ts)); }
catch { return new Date(ts).toISOString().slice(0, 10); }
}
const dayMs = (s) => Date.parse(s + 'T12:00:00Z');
const addDays = (s, n) => new Date(dayMs(s) + n * MS_DAY).toISOString().slice(0, 10);
const todayStr = () => dayStr(Date.now());
const prettyDay = (s) => new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
const prettyMonth = (m) => { const d = new Date(m + '-15T12:00:00Z'); return d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }) + (d.getUTCMonth() === 0 || true ? ` '${String(d.getUTCFullYear()).slice(2)}` : ''); };
const todayRow = () => DAILY.find((d) => d.d === todayStr()) || null;
// ── Tabs ─────────────────────────────────────────────────────────────────────
const TABS = ['now', 'charts', 'rain', 'records', 'insights'];
function setTab(name, updateHash = true) {
if (!TABS.includes(name)) name = 'now';
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-pane').forEach((p) => p.classList.toggle('active', p.dataset.pane === name));
if (updateHash) history.replaceState(null, '', '#' + name);
try { sessionStorage.setItem('dashTab', name); } catch {}
requestAnimationFrame(() => Object.values(typeof charts !== 'undefined' ? charts : {}).forEach((c) => { try { c.resize(); } catch {} }));
renderTab(name);
if (updateHash) window.scrollTo({ top: 0 });
}
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
function renderTab(name) {
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
}
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => setTab(t.dataset.tab)));
window.addEventListener('hashchange', () => setTab(location.hash.slice(1), false));
// Tabs replace the collapsible panels: keep every panel open (their lazy loaders fire on toggle)
document.querySelectorAll('details.settings-panel').forEach((d) => {
d.classList.add('always-open');
d.querySelector('summary')?.addEventListener('click', (e) => e.preventDefault());
d.open = true;
});
// Keep sticky tab bar flush under the (variable-height) header
const headerEl = document.querySelector('.header');
if (headerEl && 'ResizeObserver' in window) {
new ResizeObserver(() => document.documentElement.style.setProperty('--header-h', headerEl.offsetHeight + 'px')).observe(headerEl);
}
// ── Astronomy ────────────────────────────────────────────────────────────────
const RAD = Math.PI / 180;
function solarPos(ts, lat, lon) {
const d = ts / MS_DAY + 2440587.5 - 2451545.0;
const L = (280.46 + 0.9856474 * d) % 360;
const g = ((357.528 + 0.9856003 * d) % 360) * RAD;
const lam = (L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * RAD;
const eps = (23.439 - 0.0000004 * d) * RAD;
const dec = Math.asin(Math.sin(eps) * Math.sin(lam));
const ra = Math.atan2(Math.cos(eps) * Math.sin(lam), Math.cos(lam));
const gmst = ((280.46061837 + 360.98564736629 * d) % 360) * RAD;
const H = gmst + lon * RAD - ra;
const sinEl = Math.sin(lat * RAD) * Math.sin(dec) + Math.cos(lat * RAD) * Math.cos(dec) * Math.cos(H);
return { sinEl, elevation: Math.asin(sinEl) / RAD };
}
// Haurwitz clear-sky global horizontal irradiance (W/m²)
window.clearSkyGHI = function (ts) {
if (!LOC) return null;
const s = solarPos(ts, LOC.lat, LOC.lon).sinEl;
return s > 0.02 ? Math.round(1098 * s * Math.exp(-0.057 / s)) : 0;
};
function sunTimes(noonMs, lat, lon) {
const jd = noonMs / MS_DAY + 2440587.5;
const n = Math.ceil(jd - 2451545.0 + 0.0008);
const Js = n - lon / 360;
const M = ((357.5291 + 0.98560028 * Js) % 360) * RAD;
const C = 1.9148 * Math.sin(M) + 0.02 * Math.sin(2 * M) + 0.0003 * Math.sin(3 * M);
const lam = (((M / RAD + C + 180 + 102.9372) % 360)) * RAD;
const Jt = 2451545.0 + Js + 0.0053 * Math.sin(M) - 0.0069 * Math.sin(2 * lam);
const sinDec = Math.sin(lam) * Math.sin(23.4397 * RAD);
const cosDec = Math.cos(Math.asin(sinDec));
const cosW = (Math.sin(-0.833 * RAD) - Math.sin(lat * RAD) * sinDec) / (Math.cos(lat * RAD) * cosDec);
if (cosW > 1 || cosW < -1) return null; // polar day/night
const w = Math.acos(cosW) / RAD;
const toMs = (j) => (j - 2440587.5) * MS_DAY;
return { rise: toMs(Jt - w / 360), set: toMs(Jt + w / 360), noon: toMs(Jt) };
}
const SYNODIC = 29.530588853;
const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14);
function moonInfo(ts) {
const phase = ((((ts - NEW_MOON) / MS_DAY) / SYNODIC) % 1 + 1) % 1;
const names = ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'];
const icons = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
const i = Math.round(phase * 8) % 8;
const untilFull = ((0.5 - phase + 1) % 1) * SYNODIC;
const untilNew = ((1 - phase) % 1) * SYNODIC;
return { name: names[i], icon: icons[i], illum: (1 - Math.cos(2 * Math.PI * phase)) / 2, untilFull, untilNew };
}
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const hm = (ms) => { const m = Math.round(ms / 60000); return `${Math.floor(m / 60)}h ${m % 60}m`; };
// ── Now tab ──────────────────────────────────────────────────────────────────
const DELTAS = [
{ id: 'sHum', key: 'humidity', unit: '%', d: 0, eps: 1 },
{ id: 'sDew', key: 'dewPoint', unit: '°', d: 1, eps: 0.5 },
{ id: 'sPres', key: 'baromrelin', unit: ' inHg', d: 2, eps: 0.01 },
{ id: 'sWind', key: 'windspeedmph', unit: ' mph', d: 1, eps: 0.5 },
{ id: 'sUV', key: 'uv', unit: '', d: 1, eps: 0.5 },
{ id: 'sSolar', key: 'solarradiation', unit: ' W/m²', d: 0, eps: 10 },
];
function nearestReading(ts, tolMs = 30 * 60000) {
let best = null, bd = Infinity;
for (const r of RECENT) { const dd = Math.abs(r.dateutc - ts); if (dd < bd) { bd = dd; best = r; } }
return bd <= tolMs ? best : null;
}
function setDelta(host, key, cur, prev, unit, d, eps, after) {
if (!host) return;
let el = host.querySelector(`.delta[data-k="${key}"]`);
if (!el) {
el = document.createElement('div');
el.className = 'delta'; el.dataset.k = key;
if (after && after.parentNode === host) after.after(el); else host.appendChild(el);
}
if (num(cur) == null || num(prev) == null) { el.textContent = ''; return; }
const diff = cur - prev;
const cls = Math.abs(diff) < eps ? 'flat' : diff > 0 ? 'up' : 'down';
el.className = 'delta ' + cls;
el.textContent = cls === 'flat' ? 'Same as yesterday' : `${diff > 0 ? '▲' : '▼'} ${Math.abs(diff).toFixed(d)}${unit} vs yesterday`;
}
function drawArea(canvas, values, color) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const W = canvas.clientWidth || 200, H = canvas.clientHeight || 60;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const vals = values.filter((v) => v != null);
if (vals.length < 2) return;
const min = Math.min(...vals), max = Math.max(...vals), rng = max - min || 1;
const step = W / (values.length - 1);
const pts = [];
values.forEach((v, i) => { if (v != null) pts.push([i * step, H - 4 - ((v - min) / rng) * (H - 12)]); });
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, color + '66'); g.addColorStop(1, color + '00');
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 = 2; ctx.lineJoin = 'round'; ctx.stroke();
ctx.lineTo(pts[pts.length - 1][0], H); ctx.lineTo(pts[0][0], H); ctx.closePath();
ctx.fillStyle = g; ctx.fill();
}
function ensureHeroExtras() {
const main = document.querySelector('.hero-main');
if (!main || main.querySelector('.hero-spark')) return;
const cv = document.createElement('canvas');
cv.className = 'hero-spark'; cv.id = 'heroSpark';
main.prepend(cv);
const hilo = document.createElement('div');
hilo.className = 'hero-hilo'; hilo.id = 'heroHiLo';
$('heroWindLine')?.after(hilo);
}
function ensureWindCompass() {
if ($('windCompass')) return;
const tile = $('statWindTile');
const stats = tile?.closest('.stats');
if (!stats) return;
const wrap = document.createElement('div');
wrap.className = 'wind-wrap';
wrap.innerHTML = '<div id="windCompass"></div>';
stats.before(wrap);
wrap.appendChild(stats);
}
function updateCompass(r) {
const el = $('windCompass');
if (!el || !r) return;
const dir = num(r.winddir), spd = num(r.windspeedmph) ?? 0;
const gust = num(r.windgustmph);
const rot = dir != null ? (dir + 180) % 360 : 0; // arrow shows where the wind is heading
const ticks = Array.from({ length: 16 }, (_, i) => {
const a = i * 22.5 * RAD, big = i % 4 === 0;
const x1 = 58 + Math.sin(a) * (big ? 44 : 48), y1 = 58 - Math.cos(a) * (big ? 44 : 48);
const x2 = 58 + Math.sin(a) * 52, y2 = 58 - Math.cos(a) * 52;
return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="var(--border)" stroke-width="${big ? 1.6 : 1}"/>`;
}).join('');
el.innerHTML = `<svg viewBox="0 0 116 116" role="img" aria-label="Wind ${dir != null ? cardinal(dir) : 'calm'} ${spd.toFixed(1)} mph">
<circle cx="58" cy="58" r="52" fill="var(--paper)" stroke="var(--border)"/>${ticks}
<g font-size="9" font-weight="700" fill="var(--ink-light)" text-anchor="middle">
<text x="58" y="19">N</text><text x="100" y="61">E</text><text x="58" y="103">S</text><text x="16" y="61">W</text></g>
${spd > 0.5 && dir != null ? `<g transform="rotate(${rot} 58 58)"><path d="M58 24 L67 56 L58 50 L49 56 Z" fill="var(--accent)"/><line x1="58" y1="50" x2="58" y2="86" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/></g>` : ''}
<text x="58" y="60" text-anchor="middle" font-size="15" font-weight="800" fill="var(--ink)">${spd > 0.5 ? spd.toFixed(0) : '0'}</text>
<text x="58" y="72" text-anchor="middle" font-size="7.5" fill="var(--ink-light)">mph${dir != null && spd > 0.5 ? ' · ' + cardinal(dir) : ''}</text>
${gust != null && gust > spd + 1 ? `<text x="58" y="82" text-anchor="middle" font-size="7" fill="var(--ink-light)">gust ${gust.toFixed(0)}</text>` : ''}
</svg>`;
}
function renderNowExtras() {
ensureHeroExtras();
ensureWindCompass();
if (!LATEST) return;
const prev = nearestReading(LATEST.dateutc - MS_DAY);
// Delta lines under each tile
for (const m of DELTAS) {
const val = document.getElementById(m.id);
const host = val?.parentElement;
setDelta(host, m.key, LATEST[m.key], prev?.[m.key], m.unit, m.d, m.eps, host?.querySelector('.hero-stat-unit, .stat-unit') || val);
}
setDelta(document.querySelector('.hero-main'), 'tempf', LATEST.tempf, prev?.tempf, '°', 1, 0.5, document.querySelector('.hero-temp-row'));
// Hero 24h temperature trace
const last24 = RECENT.filter((r) => r.dateutc >= LATEST.dateutc - MS_DAY);
drawArea($('heroSpark'), last24.map((r) => r.tempf), chartColors().accent);
if (typeof updateSparklines === 'function') updateSparklines(last24);
// Today's range (from the persistent daily store — always the station-local day)
const t = todayRow();
if (t) {
if ($('heroHiLo')) $('heroHiLo').innerHTML = `<span>Today</span><span class="hi">↑ ${fmt(t.hi)}°</span><span class="lo">↓ ${fmt(t.lo)}°</span>`;
$('todayHigh').textContent = fmt(t.hi); $('todayLow').textContent = fmt(t.lo);
$('todayRain').textContent = fmt(t.rain, 2); $('todayGust').textContent = fmt(t.gust);
$('todayBar').style.display = '';
}
updateCompass(LATEST);
renderSunCard();
renderHealthCard();
renderHighlights();
}
function renderSunCard() {
const host = $('sunCard');
if (!host) return;
if (!LOC) {
host.innerHTML = `<div class="ui-card"><h3>Sun &amp; Moon</h3><p class="note">Set your station location in <a href="/dashboard/settings">Settings</a> to see sunrise, sunset, day length and moon phase.</p></div>`;
return;
}
const now = Date.now();
const n = new Date();
const noon = new Date(n.getFullYear(), n.getMonth(), n.getDate(), 12).getTime();
const st = sunTimes(noon, LOC.lat, LOC.lon);
const stTomorrow = sunTimes(noon + MS_DAY, LOC.lat, LOC.lon);
const moon = moonInfo(now);
const el = solarPos(now, LOC.lat, LOC.lon).elevation;
let arc = '';
let body = '<p class="note">No sunrise/sunset at this latitude today.</p>';
if (st) {
const f = Math.max(0, Math.min(1, (now - st.rise) / (st.set - st.rise)));
const day = now >= st.rise && now <= st.set;
const x = 20 + 260 * f, y = 76 - 58 * Math.sin(Math.PI * f);
arc = `<svg class="sun-arc" viewBox="0 0 300 86" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<line x1="10" y1="76" x2="290" y2="76" stroke="var(--border)"/>
<path d="M20 76 A130 58 0 0 1 280 76" fill="none" stroke="var(--border)" stroke-dasharray="4 4"/>
<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="${day ? '#f5b82e' : 'var(--ink-light)'}" opacity="${day ? 1 : 0.5}"/></svg>`;
const delta = stTomorrow ? (stTomorrow.set - stTomorrow.rise) - (st.set - st.rise) : null;
body = `<div class="kv">
<div><b>${clock(st.rise)}</b><span>Sunrise</span></div>
<div><b>${clock(st.set)}</b><span>Sunset</span></div>
<div><b>${hm(st.set - st.rise)}</b><span>Daylight</span>${delta != null ? `<small>${delta >= 0 ? '+' : '−'}${Math.abs(Math.round(delta / 1000))}s tomorrow</small>` : ''}</div>
<div><b>${clock(st.noon)}</b><span>Solar noon</span><small>sun ${el > 0 ? Math.round(el) + '° up' : 'below horizon'}</small></div>
</div>`;
}
host.innerHTML = `<div class="ui-card"><h3>Sun &amp; Moon</h3>${arc}${body}
<div class="kv" style="margin-top:0.9rem;padding-top:0.8rem;border-top:1px solid var(--border)">
<div><b>${moon.icon} ${Math.round(moon.illum * 100)}%</b><span>${moon.name}</span></div>
<div><b>${prettyDay(dayStr(now + moon.untilFull * MS_DAY))}</b><span>Next full</span></div>
<div><b>${prettyDay(dayStr(now + moon.untilNew * MS_DAY))}</b><span>Next new</span></div>
</div></div>`;
}
function medianIntervalMin() {
const d = [];
for (let i = 1; i < RECENT.length; i++) { const x = RECENT[i].dateutc - RECENT[i - 1].dateutc; if (x > 0 && x < 20 * 60000) d.push(x); }
if (!d.length) return null;
d.sort((a, b) => a - b);
return d[d.length >> 1] / 60000;
}
function coverageFor(row, perDay) { return row && perDay ? Math.min(100, (row.n / perDay) * 100) : null; }
function renderHealthCard() {
const host = $('healthCard');
if (!host || !LATEST) return;
const ageMin = Math.round((Date.now() - LATEST.dateutc) / 60000);
const ok = ageMin <= 10;
const interval = medianIntervalMin();
const perDay = interval ? 1440 / interval : null;
const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30);
const cov = perDay && past.length ? mean(past.map((d) => coverageFor(d, perDay))) : null;
const t = todayRow();
host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv">
<div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div>
<div><b>${LATEST.battout == null ? '—' : LATEST.battout === 1 ? 'OK' : 'Low'}</b><span>Battery</span></div>
<div><b>${interval ? interval.toFixed(interval % 1 ? 1 : 0) + ' min' : '—'}</b><span>Report interval</span></div>
<div><b>${t ? t.n.toLocaleString() : '—'}</b><span>Readings today</span></div>
<div><b>${cov != null ? cov.toFixed(0) + '%' : '—'}</b><span>30-day coverage</span>${cov != null ? `<div class="bar-meter"><i style="width:${cov.toFixed(0)}%"></i></div>` : ''}</div>
<div><b>${DAILY.length}</b><span>Days recorded</span><small>${DAILY[0] ? 'since ' + prettyDay(DAILY[0].d) : ''}</small></div>
</div></div>`;
}
function ordinal(n) { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
function renderHighlights() {
const host = $('highlights');
if (!host) return;
const t = todayRow();
const hist = DAILY.filter((d) => d.d !== todayStr());
if (!t || hist.length < 7) { host.innerHTML = ''; return; }
const chips = [];
const check = (key, label, better, minVal, icon, cool, unit, d = 1, partial = true) => {
const v = t[key];
if (num(v) == null || (minVal != null && v < minVal)) return;
const others = hist.filter((x) => num(x[key]) != null);
if (others.length < 7) return;
const rank = others.filter((x) => better(x[key], v)).length + 1;
const val = `${v.toFixed(d)}${unit}`;
const so = partial ? ' so far' : '';
if (rank === 1) { chips.push({ t: `${icon} ${label} on record (${val}${so})`, cool }); return; }
if (rank <= 3) { chips.push({ t: `${icon} ${ordinal(rank)} ${label.toLowerCase()} on record (${val}${so})`, cool }); return; }
let since = 0;
for (let i = hist.length - 1; i >= 0; i--) { if (num(hist[i][key]) != null && !better(v, hist[i][key]) && hist[i][key] !== v) { since = hist.length - i; break; } }
if (since >= 14) chips.push({ t: `${icon} ${label} in ${since} days (${val}${so})`, cool });
};
check('hi', 'Hottest day', (a, b) => a >= b, null, '🔥', false, '°');
check('lo', 'Coldest night', (a, b) => a <= b, null, '❄️', true, '°', 1, true);
check('gust', 'Windiest day', (a, b) => a >= b, 10, '💨', true, ' mph');
check('rain', 'Wettest day', (a, b) => a >= b, 0.1, '🌧', true, '"', 2);
host.innerHTML = chips.slice(0, 4).map((c) => `<span class="chip${c.cool ? ' cool' : ''}">${c.t}</span>`).join('');
}
// ── Rain tab ─────────────────────────────────────────────────────────────────
function windowDays(n) {
const end = todayStr();
const map = new Map(DAILY.map((d) => [d.d, d]));
const out = [];
for (let i = n - 1; i >= 0; i--) { const s = addDays(end, -i); out.push({ d: s, row: map.get(s) || null }); }
return out;
}
function renderRain() {
const host = $('rainTab');
if (!host) return;
if (!DAILY.length) { host.innerHTML = '<div class="status">No daily data recorded yet.</div>'; return; }
const win = windowDays(rainRange).filter((x, i, a) => x.row || i >= a.findIndex((y) => y.row));
const known = win.filter((x) => x.row && num(x.row.rain) != null);
const total = sum(known.map((x) => x.row.rain));
const rainy = known.filter((x) => x.row.rain >= 0.01);
const wettest = known.reduce((b, x) => (!b || x.row.rain > b.row.rain ? x : b), null);
let dry = 0, run = 0;
for (const x of win) { if (x.row && x.row.rain < 0.01) { run++; dry = Math.max(dry, run); } else run = 0; }
host.innerHTML = `
<div class="range-bar" id="rainRange"><span>Window:</span>
${[30, 90, 365].map((n) => `<button class="range-btn${n === rainRange ? ' active' : ''}" data-rr="${n}">${n === 365 ? '1y' : n + 'd'}</button>`).join('')}</div>
<div class="stats secondary" style="margin-bottom:1rem">
<div class="stat"><div class="stat-label">Total</div><div class="stat-val">${total.toFixed(2)}</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">Rainy days</div><div class="stat-val">${rainy.length}</div><div class="stat-unit">of ${known.length} days</div></div>
<div class="stat"><div class="stat-label">Wettest day</div><div class="stat-val">${wettest ? wettest.row.rain.toFixed(2) : '—'}</div><div class="stat-unit">${wettest ? prettyDay(wettest.d) : ''}</div></div>
<div class="stat"><div class="stat-label">Avg / rainy day</div><div class="stat-val">${rainy.length ? (total / rainy.length).toFixed(2) : '—'}</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">Longest dry spell</div><div class="stat-val">${dry}</div><div class="stat-unit">days</div></div>
</div>
<div class="charts">
<div class="chart-card wide"><div class="chart-title">Daily rain &amp; cumulative total</div><div class="chart-wrap"><canvas id="rnDaily"></canvas></div></div>
<div class="chart-card wide"><div class="chart-title">Monthly totals</div><div class="chart-wrap" style="height:200px"><canvas id="rnMonthly"></canvas></div></div>
</div>`;
host.querySelectorAll('[data-rr]').forEach((b) => b.addEventListener('click', () => { rainRange = +b.dataset.rr; renderRain(); }));
const c = chartColors();
let cum = 0;
const cumul = win.map((x) => { if (x.row && num(x.row.rain) != null) cum += x.row.rain; return +cum.toFixed(2); });
makeChart('rnDaily', 'bar', [
{ ...barDataset('Daily rain (in)', win.map((x) => (x.row ? x.row.rain : null)), c.cool), yAxisID: 'y' },
{ type: 'line', label: 'Cumulative (in)', data: cumul, borderColor: c.accent, backgroundColor: c.accent + '00', borderWidth: 2, pointRadius: 0, tension: 0.2, yAxisID: 'y2' },
], win.map((x) => prettyDay(x.d)), 'in/day', {
scales: { y: { min: 0 }, y2: { position: 'right', min: 0, grid: { drawOnChartArea: false }, ticks: { color: c.inkLight, font: { size: 11 } } } },
});
const byMonth = {};
for (const d of DAILY) if (num(d.rain) != null) { const m = d.d.slice(0, 7); byMonth[m] = (byMonth[m] || 0) + d.rain; }
const months = Object.keys(byMonth).sort();
makeChart('rnMonthly', 'bar', [barDataset('Total rain (in)', months.map((m) => +byMonth[m].toFixed(2)), c.cool)],
months.map(prettyMonth), 'in', { scales: { y: { min: 0 } } });
}
// ── Calendar (Records tab) ───────────────────────────────────────────────────
const CAL = {
hi: { label: 'High temp', get: (d) => d.hi, color: (v) => { if (v == null) return 'var(--border)'; const n = Math.max(0, Math.min(1, (v - 20) / 90)); return `rgb(${Math.round(42 + n * 182)},${Math.round(107 + n * 13)},${Math.round(138 - n * 96)})`; }, fmt: (v) => v.toFixed(1) + '°F', legend: [20, 40, 60, 80, 100] },
lo: { label: 'Low temp', get: (d) => d.lo, color: (v) => CAL.hi.color(v), fmt: (v) => v.toFixed(1) + '°F', legend: [20, 40, 60, 80, 100] },
rain: { label: 'Rain', get: (d) => d.rain, color: (v) => (v == null ? 'var(--border)' : v < 0.01 ? 'color-mix(in srgb, var(--border) 60%, transparent)' : `color-mix(in srgb, var(--cool) ${Math.round(25 + Math.min(1, v / 1.5) * 75)}%, transparent)`), fmt: (v) => v.toFixed(2) + '"', legend: [0, 0.1, 0.4, 0.8, 1.5] },
gust: { label: 'Peak gust', get: (d) => d.gust, color: (v) => (v == null ? 'var(--border)' : `color-mix(in srgb, var(--accent) ${Math.round(10 + Math.min(1, v / 40) * 90)}%, transparent)`), fmt: (v) => v.toFixed(0) + ' mph', legend: [0, 10, 20, 30, 40] },
};
function renderCalendar() {
const host = $('calendarCard');
if (!host) return;
if (!DAILY.length) { host.innerHTML = '<div class="status">No daily data yet.</div>'; return; }
const M = CAL[calMetric];
const map = new Map(DAILY.map((d) => [d.d, d]));
const end = todayStr();
let start = addDays(end, -364);
const dow = new Date(dayMs(start)).getUTCDay();
start = addDays(start, -dow);
const cells = [], labels = [];
let col = 0, lastMonth = '';
for (let s = start, i = 0; s <= end; s = addDays(s, 1), i++) {
if (i % 7 === 0) {
col = i / 7;
const m = s.slice(0, 7);
if (m !== lastMonth && s >= addDays(end, -364)) { labels.push(`<span style="grid-column:${col + 1}">${new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })}</span>`); lastMonth = m; }
}
const row = map.get(s), v = row ? M.get(row) : null;
const before = s < addDays(end, -364);
cells.push(`<div class="heatmap-cell${s === end ? ' today' : ''}" style="background:${before ? 'transparent' : M.color(num(v))}" title="${prettyDay(s)}${num(v) != null ? ': ' + M.fmt(v) : ''}"></div>`);
}
host.innerHTML = `<div class="section-card-hdr">Calendar</div>
<div class="cal-bar">${Object.entries(CAL).map(([k, m]) => `<button class="range-btn${k === calMetric ? ' active' : ''}" data-cal="${k}">${m.label}</button>`).join('')}</div>
<div class="heatmap-wrap"><div class="cal-months">${labels.join('')}</div><div class="heatmap">${cells.join('')}</div></div>
<div class="heatmap-legend"><span>Low</span>${M.legend.map((v) => `<div class="heatmap-legend-swatch" style="background:${M.color(v)}"></div>`).join('')}<span>High</span></div>`;
host.querySelectorAll('[data-cal]').forEach((b) => b.addEventListener('click', () => { calMetric = b.dataset.cal; renderCalendar(); }));
const wrap = host.querySelector('.heatmap-wrap');
if (wrap) wrap.scrollLeft = wrap.scrollWidth; // show the most recent weeks on phones
}
// ── Insights tab ─────────────────────────────────────────────────────────────
function drawRose(canvas, buckets) {
const dpr = window.devicePixelRatio || 1, S = 170;
canvas.width = S * dpr; canvas.height = S * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cs = getComputedStyle(document.documentElement);
const border = cs.getPropertyValue('--border').trim(), accent = cs.getPropertyValue('--accent').trim(), light = cs.getPropertyValue('--ink-light').trim();
const cx = S / 2, R = S / 2 - 18, max = Math.max(...buckets, 1), sl = (Math.PI * 2) / 16;
for (let i = 1; i <= 3; i++) { ctx.beginPath(); ctx.arc(cx, cx, (R * i) / 3, 0, Math.PI * 2); ctx.strokeStyle = border + '88'; ctx.lineWidth = 0.6; ctx.stroke(); }
ctx.fillStyle = light; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
['N', 'E', 'S', 'W'].forEach((l, i) => { const a = (i * Math.PI) / 2 - Math.PI / 2; ctx.fillText(l, cx + Math.cos(a) * (R + 10), cx + Math.sin(a) * (R + 10)); });
buckets.forEach((b, i) => {
const len = (b / max) * R; if (len < 1) return;
const a = i * sl - Math.PI / 2 - sl / 2;
ctx.beginPath(); ctx.moveTo(cx, cx); ctx.arc(cx, cx, len, a, a + sl); ctx.closePath();
ctx.fillStyle = accent + 'cc'; ctx.fill(); ctx.strokeStyle = accent; ctx.lineWidth = 0.5; ctx.stroke();
});
}
function seasonOf(month, south) {
const m = +month;
const s = m === 12 || m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3; // DJF MAM JJA SON
return south ? (s + 2) % 4 : s;
}
function trendRow(label, cur, prev, d, unit, goodUp) {
if (cur == null || prev == null) return `<div class="cmp-row"><span>${label}</span><b>—</b></div>`;
const diff = cur - prev;
const cls = Math.abs(diff) < Math.pow(10, -d) ? 'flat' : diff > 0 ? 'up' : 'down';
return `<div class="cmp-row"><span>${label}</span><span><b>${cur.toFixed(d)}${unit}</b> <span class="delta ${cls}" style="display:inline">${cls === 'flat' ? '=' : (diff > 0 ? '▲ ' : '▼ ') + Math.abs(diff).toFixed(d) + unit}</span></span></div>`;
}
function renderInsights() {
const host = $('insightsBody');
if (!host) return;
if (DAILY.length < 3) { host.innerHTML = '<div class="status">Insights unlock after a few days of recorded data.</div>'; return; }
const past = DAILY.filter((d) => d.d !== todayStr());
const W = Math.min(30, Math.floor(past.length / 2));
let compare = '<p class="note">Needs at least 14 recorded days to compare periods.</p>';
if (W >= 7) {
const cur = past.slice(-W), prev = past.slice(-2 * W, -W);
const col = (a, k) => a.map((x) => x[k]).filter((v) => num(v) != null);
compare = `<p class="note" style="margin-bottom:0.5rem">Last ${W} days vs the ${W} before</p>
${trendRow('Avg temp', mean(col(cur, 'avg')), mean(col(prev, 'avg')), 1, '°')}
${trendRow('Avg high', mean(col(cur, 'hi')), mean(col(prev, 'hi')), 1, '°')}
${trendRow('Avg low', mean(col(cur, 'lo')), mean(col(prev, 'lo')), 1, '°')}
${trendRow('Rain total', sum(col(cur, 'rain')), sum(col(prev, 'rain')), 2, '"')}
${trendRow('Rainy days', col(cur, 'rain').filter((v) => v >= 0.01).length, col(prev, 'rain').filter((v) => v >= 0.01).length, 0, '')}
${trendRow('Peak gust', Math.max(...col(cur, 'gust'), 0), Math.max(...col(prev, 'gust'), 0), 0, ' mph')}`;
}
// Normals: this calendar month vs the same month in earlier years
const thisMonth = todayStr().slice(5, 7), thisYear = todayStr().slice(0, 4);
const monthRows = (y) => DAILY.filter((d) => d.d.startsWith(`${y}-${thisMonth}`));
const years = [...new Set(DAILY.map((d) => d.d.slice(0, 4)))].filter((y) => y !== thisYear && monthRows(y).length >= 20);
const curRows = monthRows(thisYear);
let normals = `<p class="note">Station normals unlock once a full year of data is recorded — ${DAILY.length} day${DAILY.length === 1 ? '' : 's'} so far. Until then, use the period comparison.</p>`;
if (years.length && curRows.length) {
const avgOf = (y, k) => mean(monthRows(y).map((d) => d[k]).filter((v) => num(v) != null));
const normAvg = (k) => mean(years.map((y) => avgOf(y, k)).filter((v) => v != null));
const rainNorm = mean(years.map((y) => sum(monthRows(y).map((d) => d.rain || 0))));
normals = `<p class="note" style="margin-bottom:0.5rem">This month vs ${years.length}-year station average</p>
${trendRow('Avg temp', mean(curRows.map((d) => d.avg).filter((v) => v != null)), normAvg('avg'), 1, '°')}
${trendRow('Avg high', mean(curRows.map((d) => d.hi).filter((v) => v != null)), normAvg('hi'), 1, '°')}
${trendRow('Avg low', mean(curRows.map((d) => d.lo).filter((v) => v != null)), normAvg('lo'), 1, '°')}
${trendRow('Rain (month to date vs full month avg)', sum(curRows.map((d) => d.rain || 0)), rainNorm, 2, '"')}`;
}
host.innerHTML = `
<div class="insight-grid">
<div class="ui-card"><h3>Trends</h3>${compare}</div>
<div class="ui-card"><h3>Vs normal</h3>${normals}</div>
</div>
<div class="charts" style="margin-bottom:1rem">
<div class="chart-card wide"><div class="chart-title">Daily temperature range (last 60 days)</div><div class="chart-wrap"><canvas id="inDiurnal"></canvas></div></div>
<div class="chart-card wide"><div class="chart-title">Data coverage by day</div><div class="chart-wrap" style="height:150px"><canvas id="inCoverage"></canvas></div></div>
</div>
<div class="ui-card" style="margin-bottom:1rem"><h3>Prevailing wind by season</h3><div class="roses" id="seasonRoses"></div>
<p class="note" style="margin-top:0.7rem">Share of readings with measurable wind, by direction it blows from.</p></div>`;
const c = chartColors();
const last60 = past.slice(-60);
makeChart('inDiurnal', 'bar', [
{ label: 'Low–High', data: last60.map((d) => (d.lo != null && d.hi != null ? [d.lo, d.hi] : null)), backgroundColor: c.accent + '77', borderColor: c.accent, borderWidth: 1, borderRadius: 3, borderSkipped: false },
{ type: 'line', label: 'Average', data: last60.map((d) => d.avg), borderColor: c.cool, backgroundColor: c.cool + '00', borderWidth: 2, pointRadius: 0, tension: 0.3 },
], last60.map((d) => prettyDay(d.d)), '°F', (() => {
const los = last60.map((d) => d.lo).filter((v) => v != null), his = last60.map((d) => d.hi).filter((v) => v != null);
return los.length ? { scales: { y: { min: Math.floor(Math.min(...los) - 3), max: Math.ceil(Math.max(...his) + 3) } } } : {};
})());
const interval = medianIntervalMin();
const perDay = interval ? 1440 / interval : null;
const cov = past.slice(-45);
if (perDay) {
const vals = cov.map((d) => +coverageFor(d, perDay).toFixed(0));
const ds = barDataset('Coverage %', vals, c.cool);
ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb'));
makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } });
}
const south = LOC && LOC.lat < 0;
const names = south ? ['Summer', 'Autumn', 'Winter', 'Spring'] : ['Winter', 'Spring', 'Summer', 'Fall'];
const seasons = names.map(() => new Array(16).fill(0));
for (const d of DAILY) if (d.wd) { const s = seasonOf(d.d.slice(5, 7), south); d.wd.forEach((v, i) => { seasons[s][i] += v; }); }
const roses = $('seasonRoses');
const cards = seasons.map((b, i) => ({ name: names[i], b, total: sum(b) })).filter((x) => x.total > 0);
if (!cards.length) roses.innerHTML = '<p class="note">Wind direction history builds up as new readings are saved.</p>';
cards.forEach((x) => {
const div = document.createElement('div');
const top = x.b.indexOf(Math.max(...x.b));
div.innerHTML = `<canvas></canvas><small><b>${x.name}</b> · from ${cardinal(top * 22.5)} (${Math.round((x.b[top] / x.total) * 100)}%)</small>`;
roses.appendChild(div);
drawRose(div.querySelector('canvas'), x.b);
});
}
// ── Who's signed in: role-aware header links ─────────────────────────────────
(async function roleAwareHeader() {
try {
const me = await getJSON('/api/dashboard/me');
const right = document.querySelector('.header-right');
if (!right) return;
const settings = right.querySelector('a[href="/dashboard/settings"]');
if (me.role !== 'admin' && settings) settings.remove();
const acct = document.createElement('a');
acct.href = '/dashboard/account'; acct.className = 'logout'; acct.textContent = me.role === 'admin' ? 'Account & Users' : 'Account';
acct.title = `Signed in as ${me.username} (${me.role})`;
(settings && me.role === 'admin' ? settings : right.querySelector('a[href="/dashboard/logout"]'))?.before(acct);
const nm = $('stationName');
if (nm && !nm.textContent) nm.textContent = me.username;
window.DASH_USER = me;
} catch {}
})();
// ── Data refresh ─────────────────────────────────────────────────────────────
async function refreshData() {
try {
const [daily, recent] = await Promise.all([
getJSON('/api/station/daily').catch(() => []),
getJSON('/api/station/history?range=2d').catch(() => []),
]);
DAILY = daily; RECENT = recent;
LATEST = RECENT[RECENT.length - 1] || null;
if (LATEST?.tz) TZ = LATEST.tz;
if (!LOC) {
const loc = await getJSON('/api/dashboard/location').catch(() => null);
if (loc && num(loc.lat) != null) {
LOC = loc; window.STATION_LOC = loc;
if (typeof loadHistory === 'function') loadHistory(currentRange); // redraw so clear-sky overlay appears
}
}
renderNowExtras();
rendered.rain = rendered.cal = rendered.insights = false;
renderTab(activeTab());
} catch (e) { if (e && e.message !== 'auth') console.error('[dashboard-ui]', e); }
}
document.getElementById('refreshBtn')?.addEventListener('click', refreshData);
setInterval(refreshData, 120000);
let initial = location.hash.slice(1);
if (!TABS.includes(initial)) { try { initial = sessionStorage.getItem('dashTab') || 'now'; } catch { initial = 'now'; } }
setTab(initial, false);
refreshData();
})();
+194 -88
View File
@@ -2,22 +2,33 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <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/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> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649; --ink: #0d1b2a; --ink-mid: #2b3f55; --ink-light: #5a7089;
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7; --paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a; --border: #ccd6e2; --accent: #c25a2a; --cool: #2a6b8a;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif; --ui: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399; --ink: #dce6f0; --ink-mid: #b7c6d6; --ink-light: #8aa2b8;
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620; --paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29;
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8; --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; } html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
@@ -321,6 +332,47 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
box-shadow: 0 0 0 2px rgba(255,195,0,0.12); box-shadow: 0 0 0 2px rgba(255,195,0,0.12);
} }
.record-best .record-label::after { content: ' ★'; color: #ffc300; } .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> </style>
</head> </head>
<body> <body>
@@ -332,17 +384,27 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div> </div>
<div class="header-right"> <div class="header-right">
<span class="station-name" id="stationName"></span> <span class="station-name" id="stationName"></span>
<a href="/conditions" class="logout">Conditions</a> <a href="/?tab=station" class="logout">Station</a>
<a href="/" class="logout">← Weather</a> <a href="/" class="logout">← Weather</a>
<a href="/dashboard/settings" class="logout">Settings</a> <a href="/dashboard/settings" class="logout">Settings</a>
<a href="/dashboard/logout" class="logout">Sign out</a> <a href="/dashboard/logout" class="logout">Sign out</a>
</div> </div>
</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="page">
<div class="offline-banner" id="offlineBanner"></div> <div class="offline-banner" id="offlineBanner"></div>
<div id="alertsSection" style="margin-bottom:0"></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 --> <!-- Hero: Current Conditions -->
<div class="section-card"> <div class="section-card">
<div class="section-card-hdr">Current Conditions</div> <div class="section-card-hdr">Current Conditions</div>
@@ -472,6 +534,10 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</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 --> <!-- Range selector -->
<div class="range-bar"> <div class="range-bar">
<span>History:</span> <span>History:</span>
@@ -494,6 +560,11 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
<div class="status" id="loadingMsg">Loading history…</div> <div class="status" id="loadingMsg">Loading history…</div>
</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 --> <!-- Records -->
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem"> <details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
<summary>All-Time Station Records</summary> <summary>All-Time Station Records</summary>
@@ -510,14 +581,12 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div> </div>
</details> </details>
<!-- Temperature Heatmap --> <div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
<details class="settings-panel" id="heatmapPanel" style="margin-top:1rem">
<summary>Temperature Calendar</summary>
<div class="settings-body" id="heatmapBody">
<div class="status">Loading…</div>
</div>
</details>
</div><!-- /records -->
<div class="tab-pane" data-pane="insights" id="insightsTab">
<div id="insightsBody"><div class="status">Loading insights…</div></div>
<!-- Alert History --> <!-- Alert History -->
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem"> <details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
<summary>Alert History</summary> <summary>Alert History</summary>
@@ -525,6 +594,7 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
<div class="status">Loading…</div> <div class="status">Loading…</div>
</div> </div>
</details> </details>
</div><!-- /insights -->
</div> </div>
@@ -577,6 +647,10 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
plugins: { 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 } }, legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
tooltip: { tooltip: {
backgroundColor: 'rgba(0,0,0,0.75)', backgroundColor: 'rgba(0,0,0,0.75)',
@@ -601,9 +675,23 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
const mergedOpts = deepMerge(defaults, opts); const mergedOpts = deepMerge(defaults, opts);
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts }); charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
ctx.ondblclick = () => { charts[canvasId].resetZoom(); markZoomed(charts[canvasId], false); };
return charts[canvasId]; 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) { function deepMerge(a, b) {
const out = { ...a }; const out = { ...a };
for (const k of Object.keys(b || {})) { for (const k of Object.keys(b || {})) {
@@ -745,6 +833,11 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-wrap"><canvas id="cPres"></canvas></div> <div class="chart-wrap"><canvas id="cPres"></canvas></div>
</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 --> <!-- Rain -->
<div class="chart-card"> <div class="chart-card">
<div class="chart-title">Rain Rate (in/hr)</div> <div class="chart-title">Rain Rate (in/hr)</div>
@@ -768,18 +861,27 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-wrap"><canvas id="cSolar"></canvas></div> <div class="chart-wrap"><canvas id="cSolar"></canvas></div>
</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> </div>
`; `;
const tempDatasets = [ const tempDatasets = [];
lineDataset('Temp °F', field(pts, 'tempf'), c.accent), if (pts.some(r => r.tempMax != null)) {
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true), // 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) { if (yoy?.prior?.length) {
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior; 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 }); tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
} }
makeChart('cTemp', 'line', tempDatasets, lbls, '°F'); makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
makeChart('cHum', 'line', makeChart('cHum', 'line',
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool), [lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
@@ -811,13 +913,38 @@ function renderCharts(readings, range, yoy = null) {
[lineDataset('UV Index', field(pts, 'uv'), c.accent)], [lineDataset('UV Index', field(pts, 'uv'), c.accent)],
lbls, null, { scales: { y: { min: 0 } } }); lbls, null, { scales: { y: { min: 0 } } });
makeChart('cSolar', 'line', const solarSets = [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)];
[lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)], if (typeof clearSkyGHI === 'function' && ['1h', '6h', '24h', '2d', '7d'].includes(range) && window.STATION_LOC) {
lbls, 'W/m²', { scales: { y: { min: 0 } } }); 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);
}
// Today bar + sparklines use full readings (not downsampled pts)
updateTodayBar(readings);
updateSparklines(readings);
// Wind rose // Wind rose
const roseCanvas = document.getElementById('windRoseCanvas'); const roseCanvas = document.getElementById('windRoseCanvas');
@@ -944,7 +1071,8 @@ async function loadHistory(range) {
const area = document.getElementById('chartsArea'); const area = document.getElementById('chartsArea');
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>'; area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
try { try {
const r = await fetch(`/api/station/history?range=${range}`); 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.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error('Failed'); if (!r.ok) throw new Error('Failed');
const data = await r.json(); const data = await r.json();
@@ -952,7 +1080,7 @@ async function loadHistory(range) {
const line = document.getElementById('updatedLine'); const line = document.getElementById('updatedLine');
if (line) { if (line) {
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
line.textContent = `${data.length} readings · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`; 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) { } catch (e) {
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`; area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
@@ -1036,6 +1164,36 @@ function fmtDate(ts) {
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); 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() { async function loadRecords() {
const panel = document.getElementById('recordsPanel'); const panel = document.getElementById('recordsPanel');
if (!panel) return; if (!panel) return;
@@ -1048,7 +1206,7 @@ async function loadRecords() {
if (!r.ok) { body.innerHTML = '<div class="status">No records yet.</div>'; return; } if (!r.ok) { body.innerHTML = '<div class="status">No records yet.</div>'; return; }
const d = await r.json(); const d = await r.json();
if (!d) { body.innerHTML = '<div class="status">No data recorded yet.</div>'; return; } if (!d) { body.innerHTML = '<div class="status">No data recorded yet.</div>'; return; }
const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings since ${fmtDate(d.firstReading)}` : ''; const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings over ${d.daysRecorded} days since ${fmtDate(d.firstReading)}` : '';
const cutoff = Date.now() - 30 * 24 * 3600 * 1000; const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
const ri = (label, val, date) => { const ri = (label, val, date) => {
const recent = date && new Date(date).getTime() > cutoff; const recent = date && new Date(date).getTime() > cutoff;
@@ -1068,6 +1226,11 @@ async function loadRecords() {
${ri('Lowest Pressure', d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—', d.lowPressureDate)} ${ri('Lowest Pressure', d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—', d.lowPressureDate)}
${ri('Highest Humidity', d.highHumidity != null ? d.highHumidity + '%' : '—', d.highHumidityDate)} ${ri('Highest Humidity', d.highHumidity != null ? d.highHumidity + '%' : '—', d.highHumidityDate)}
${ri('Lowest Humidity', d.lowHumidity != null ? d.lowHumidity + '%' : '—', d.lowHumidityDate)} ${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>`; </div>`;
} catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; } } catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; }
}, { once: false }); }, { once: false });
@@ -1167,64 +1330,6 @@ if (monthlyPanel) {
}, { once: false }); }, { once: false });
} }
// ── Temperature heatmap ───────────────────────────────────────────────────────
const heatmapPanel = document.getElementById('heatmapPanel');
if (heatmapPanel) {
heatmapPanel.addEventListener('toggle', async () => {
if (!heatmapPanel.open) return;
const body = document.getElementById('heatmapBody');
if (body.querySelector('.heatmap-wrap')) return;
try {
const r = await fetch('/api/station/history?range=1y');
if (!r.ok) throw new Error();
const readings = await r.json();
if (!readings.length) { body.innerHTML = '<div class="status">No data yet.</div>'; return; }
// Daily high temp
const byDay = {};
for (const rd of readings) {
if (rd.tempf == null || !rd.dateutc) continue;
const day = new Date(rd.dateutc).toISOString().slice(0, 10);
if (byDay[day] == null || rd.tempf > byDay[day]) byDay[day] = rd.tempf;
}
// Color scale: cold = cool blue, hot = accent orange
function heatColor(t) {
if (t == null) return 'var(--border)';
const norm = Math.max(0, Math.min(1, (t - 20) / 90));
const r = Math.round(42 + norm * (224 - 42));
const g = Math.round(107 + norm * (120 - 107));
const b = Math.round(138 + norm * (42 - 138));
return `rgb(${r},${g},${b})`;
}
// Build grid: iterate days from 1yr ago to today
const today = new Date(); today.setHours(23, 59, 59, 0);
const start = new Date(today); start.setFullYear(start.getFullYear() - 1); start.setDate(start.getDate() + 1);
// Align to Sunday
const startDow = start.getDay(); // 0=Sun
const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null); // padding
const cur = new Date(start);
while (cur <= today) {
const key = cur.toISOString().slice(0, 10);
cells.push({ date: key, temp: byDay[key] ?? null });
cur.setDate(cur.getDate() + 1);
}
const cellHtml = cells.map(c => {
if (!c) return `<div class="heatmap-cell" style="background:transparent"></div>`;
const t = c.temp;
const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date;
return `<div class="heatmap-cell" style="background:${heatColor(t)}" title="${label}"></div>`;
}).join('');
body.innerHTML = `
<div class="heatmap-wrap"><div class="heatmap">${cellHtml}</div></div>
<div class="heatmap-legend">
<span>Cooler</span>
${[20,35,50,65,80,95,110].map(t => `<div class="heatmap-legend-swatch" style="background:${heatColor(t)}"></div>`).join('')}
<span>Hotter</span>
</div>`;
} catch { document.getElementById('heatmapBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
}, { once: false });
}
// ── Tile color coding ───────────────────────────────────────────────────────── // ── Tile color coding ─────────────────────────────────────────────────────────
function tempColorClass(t) { function tempColorClass(t) {
if (t == null) return ''; if (t == null) return '';
@@ -1490,5 +1595,6 @@ setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000); setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000); setInterval(loadAlerts, 5 * 60 * 1000);
</script> </script>
<script src="/dashboard-ui.js"></script>
</body> </body>
</html> </html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Station Dashboard",
"short_name": "Station",
"description": "Personal weather station dashboard",
"start_url": "/dashboard",
"scope": "/dashboard",
"display": "standalone",
"background_color": "#070c14",
"theme_color": "#070c14",
"orientation": "any",
"categories": ["weather", "utilities"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+131 -207
View File
@@ -6,45 +6,48 @@
<title>NeCloud Weather</title> <title>NeCloud Weather</title>
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon.svg"> <link rel="apple-touch-icon" href="/icons/icon.svg">
<meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#edf2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#191510" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#070c14" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NeCloud Weather"> <meta name="apple-mobile-web-app-title" content="NeCloud Weather">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"> <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="station-live.css">
<link rel="stylesheet" href="app-ui.css">
</head> </head>
<body> <body>
<div id="offline-banner" class="offline-banner hidden">No internet connection — showing cached data</div> <div id="offline-banner" class="offline-banner hidden">No internet connection — showing cached data</div>
<div id="ptr-indicator" class="ptr-indicator hidden">↓ Refreshing…</div> <div id="ptr-indicator" class="ptr-indicator hidden">↓ Refreshing…</div>
<div class="app"> <div class="app">
<header class="masthead"> <header class="topbar">
<h1>NeCloud Weather</h1> <a class="brand" href="/" aria-label="NeCloud Weather home"><span class="brand-mark">☁︎</span><span class="brand-name">NeCloud</span></a>
<nav class="tabs"> <nav class="tabs" aria-label="Sections">
<button class="tab active" data-tab="conditions">Conditions</button> <button class="tab active" data-tab="conditions">Now</button>
<button class="tab" data-tab="station">Station</button>
<button class="tab" data-tab="unified-map">Map</button> <button class="tab" data-tab="unified-map">Map</button>
<button class="tab" data-tab="alerts">Alerts<span class="alert-badge hidden" id="alert-badge-nav"></span></button> <button class="tab" data-tab="alerts">Alerts<span class="alert-badge hidden" id="alert-badge-nav"></span></button>
<button class="tab" data-tab="learn">Learn</button>
<button class="tab" data-tab="settings">Settings</button>
</nav>
<span class="tagline">Open-Meteo · RainViewer</span>
<nav class="station-links">
<a href="/conditions" class="station-link">Station</a>
<a href="/dashboard" class="station-link">Dashboard</a>
</nav> </nav>
<div class="topbar-actions">
<button class="loc-chip" id="tb-search" type="button" aria-label="Change location"><span class="loc-chip-ico">⌕</span><span id="tb-loc">Location</span></button>
<a class="icon-btn" href="/dashboard" title="Station dashboard" aria-label="Station dashboard">▤</a>
<button class="icon-btn" data-tab="learn" type="button" title="Learn" aria-label="Learn">?</button>
<button class="icon-btn" data-tab="settings" type="button" title="Settings" aria-label="Settings">⚙</button>
</div>
</header> </header>
<!-- Loading --> <!-- Loading: skeleton of the Now tab -->
<div id="loading" class="loading hidden"> <div id="loading" class="loading hidden" aria-busy="true" aria-label="Loading weather">
<div class="loading-container"> <div class="sk sk-hero"></div>
<div class="loading-icon">🌤️</div> <div class="sk sk-line"></div>
<div class="loading-text">Fetching weather data…</div> <div class="sk sk-card"></div>
<div class="loading-dots"> <div class="sk sk-card tall"></div>
<span></span><span></span><span></span> <div class="sk-rows"><div class="sk sk-row"></div><div class="sk sk-row"></div><div class="sk sk-row"></div></div>
</div>
</div>
</div> </div>
<!-- ── TAB: CONDITIONS ── --> <!-- ── TAB: CONDITIONS ── -->
@@ -62,8 +65,9 @@
</div> </div>
</div> </div>
<!-- ── HERO ── --> <!-- ── HERO (dynamic sky) ── -->
<div class="hero"> <div class="hero" id="hero">
<canvas id="sky-fx" class="sky-fx" aria-hidden="true"></canvas>
<div class="hero-left"> <div class="hero-left">
<div id="cur-icon" class="hero-icon"></div> <div id="cur-icon" class="hero-icon"></div>
<div id="cur-desc" class="hero-desc"></div> <div id="cur-desc" class="hero-desc"></div>
@@ -73,6 +77,7 @@
<div class="hero-center"> <div class="hero-center">
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div> <div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
<div class="feels" id="feels-line">Feels like <span id="cur-feels">--</span>°F</div> <div class="feels" id="feels-line">Feels like <span id="cur-feels">--</span>°F</div>
<div class="hero-context hidden" id="hero-context"></div>
<button id="share-hero-btn" class="hero-share-btn" type="button">Share hero card</button> <button id="share-hero-btn" class="hero-share-btn" type="button">Share hero card</button>
<p id="share-hero-status" class="hero-share-status hidden"></p> <p id="share-hero-status" class="hero-share-status hidden"></p>
</div> </div>
@@ -93,107 +98,40 @@
</div> </div>
</div> </div>
<!-- ── SUN & MOON TIMELINE ── --> <!-- ── ONE-LINE SUMMARY ── -->
<div class="sun-section"> <div class="summary-line" id="summary-line"></div>
<div class="sun-timeline-wrap">
<div class="sun-endpoint"> <!-- ── NEXT 2 HOURS + WHAT TO BRING ── -->
<span class="sun-ep-icon">🌅</span> <section class="ui-card nowcast" id="nowcast">
<span id="sunrise-val" class="sun-ep-time">--</span> <div class="section-head">
</div> <span class="section-label">Next 2 hours</span>
<div class="sun-track-wrap"> <span class="section-note" id="nowcast-note"></span>
<div class="sun-track">
<div class="sun-progress" id="sun-progress"></div>
<div class="sun-dot" id="sun-dot">☀️</div>
</div>
</div>
<div class="sun-endpoint">
<span class="sun-ep-icon">🌇</span>
<span id="sunset-val" class="sun-ep-time">--</span>
</div>
</div> </div>
<div class="moon-row"> <div class="nowcast-bars" id="nowcast-bars"></div>
<span class="moon-emoji">🌙</span> <div class="bring-row">
<span id="moon-phase-val">--</span> <span class="bring-label">Bring</span>
<span class="daylight-info" id="daylight-info">--</span> <div id="recommendations" class="recs-grid"></div>
</div> </div>
</section>
<!-- ── HOURLY ── -->
<div class="section-head sticky-head">
<span class="section-label">Hourly Forecast</span>
<span class="section-note">
<button id="hourly-feels-toggle" class="link-btn">Feels-like</button>
<button id="hourly-extend-btn" class="link-btn">48h</button>
</span>
</div>
<div class="ui-card hourly-card">
<div class="hc-readout" id="hc-readout"></div>
<div id="hourly-chart" class="hourly-chart" aria-label="Hourly temperature, rain chance and wind chart"></div>
</div>
<div id="hourly-scroll" class="hourly-scroll">
<div id="hourly-track" class="hourly-track"></div>
</div> </div>
<!-- ── WIND & ATMOSPHERE (collapsed by default on mobile) ── --> <!-- ── RADAR ── -->
<details class="details-drawer wind-drawer" id="wind-drawer"> <div class="section-head sticky-head">
<summary class="drawer-summary">
Wind &amp; Atmosphere
<span class="wind-drawer-peek" id="wind-drawer-peek"></span>
<span class="drawer-chevron">›</span>
</summary>
<div class="drawer-body">
<div class="card-group drawer-card-group">
<div class="stat-card">
<div class="stat-label">Wind</div>
<div class="stat-val"><span id="cur-wind">--</span> <span class="stat-unit wind-unit">mph</span></div>
<div class="stat-sub" id="cur-wind-dir">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Gusts</div>
<div class="stat-val"><span id="cur-gust">--</span> <span class="stat-unit gust-unit">mph</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Humidity</div>
<div class="stat-val"><span id="cur-hum">--</span><span class="stat-unit">%</span></div>
<div class="stat-sub" id="hum-feel"></div>
</div>
<div class="stat-card">
<div class="stat-label">Dewpoint</div>
<div class="stat-val"><span id="cur-dew">--</span>°</div>
</div>
<div class="stat-card">
<div class="stat-label">Pressure</div>
<div class="stat-val"><span id="cur-pres">--</span> <span class="stat-unit">hPa</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Visibility</div>
<div class="stat-val"><span id="cur-vis">--</span> <span class="stat-unit vis-unit">mi</span></div>
</div>
</div>
</div>
</details>
<!-- ── MORE DETAILS DRAWER ── -->
<details class="details-drawer">
<summary class="drawer-summary">More Details <span class="drawer-chevron">›</span></summary>
<div class="drawer-body">
<div class="card-group drawer-card-group">
<div class="stat-card">
<div class="stat-label">UV Index</div>
<div class="stat-val"><span id="cur-uv">--</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Cloud Cover</div>
<div class="stat-val"><span id="cur-cloud">--</span><span class="stat-unit">%</span></div>
</div>
<div class="stat-card">
<div class="stat-label">US AQI</div>
<div class="stat-val"><span id="aqi-us">--</span></div>
</div>
<div class="stat-card">
<div class="stat-label">PM2.5</div>
<div class="stat-val"><span id="pm25">--</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Pollen</div>
<div class="stat-val"><span id="pollen">--</span></div>
</div>
</div>
</div>
</details>
<!-- ── WHAT TO BRING ── -->
<div class="section-head" style="margin-top:1.5rem">
<span class="section-label">What to Bring</span>
</div>
<div id="recommendations" class="recs-grid"></div>
<!-- Embedded radar panel -->
<div class="section-head">
<span class="section-label">Radar</span> <span class="section-label">Radar</span>
<span class="section-note"> <span class="section-note">
<button class="link-btn" id="open-map-tab">Open map →</button> <button class="link-btn" id="open-map-tab">Open map →</button>
@@ -210,26 +148,71 @@
</div> </div>
</div> </div>
<!-- Hourly --> <!-- ── 7-DAY ── -->
<div class="section-head" style="margin-top:1.5rem"> <div class="section-head sticky-head">
<span class="section-label">Hourly Forecast</span>
<span class="section-note">
<button id="hourly-feels-toggle" class="link-btn">Feels-like</button>
<button id="hourly-extend-btn" class="link-btn">48h</button>
</span>
</div>
<div id="hourly-scroll" class="hourly-scroll">
<div id="hourly-track" class="hourly-track"></div>
</div>
<div id="precip-chart-wrap" class="precip-chart-wrap"></div>
<!-- 7-day -->
<div class="section-head">
<span id="daily-forecast-label" class="section-label">7-Day Forecast</span> <span id="daily-forecast-label" class="section-label">7-Day Forecast</span>
<button id="daily-toggle-btn" class="link-btn" type="button">Show 16-day</button> <button id="daily-toggle-btn" class="link-btn" type="button">Show 16-day</button>
</div> </div>
<div id="daily-list" class="daily-list"></div> <div id="daily-list" class="daily-list"></div>
<!-- ── DETAILS TILES ── -->
<div class="section-head sticky-head">
<span class="section-label">Details</span>
<span class="section-note" id="wind-drawer-peek"></span>
</div>
<div class="detail-grid">
<div class="stat-card">
<div class="stat-label">Wind</div>
<div class="stat-val"><span id="cur-wind">--</span> <span class="stat-unit wind-unit">mph</span></div>
<div class="stat-sub" id="cur-wind-dir">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Gusts</div>
<div class="stat-val"><span id="cur-gust">--</span> <span class="stat-unit gust-unit">mph</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Humidity</div>
<div class="stat-val"><span id="cur-hum">--</span><span class="stat-unit">%</span></div>
<div class="stat-sub" id="hum-feel"></div>
</div>
<div class="stat-card">
<div class="stat-label">Dewpoint</div>
<div class="stat-val"><span id="cur-dew">--</span>°</div>
</div>
<div class="stat-card">
<div class="stat-label">Pressure</div>
<div class="stat-val"><span id="cur-pres">--</span> <span class="stat-unit">hPa</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Visibility</div>
<div class="stat-val"><span id="cur-vis">--</span> <span class="stat-unit vis-unit">mi</span></div>
</div>
<div class="stat-card">
<div class="stat-label">UV Index</div>
<div class="stat-val"><span id="cur-uv">--</span></div>
<div class="stat-sub" id="uv-cat"></div>
</div>
<div class="stat-card">
<div class="stat-label">Cloud Cover</div>
<div class="stat-val"><span id="cur-cloud">--</span><span class="stat-unit">%</span></div>
</div>
<div class="stat-card aqi-card">
<div class="stat-label">Air Quality (US AQI)</div>
<div class="stat-val"><span id="aqi-us">--</span> <span class="aqi-cat" id="aqi-cat"></span></div>
<div class="aqi-track"><span class="aqi-marker" id="aqi-marker"></span></div>
<div class="stat-sub">PM2.5 <span id="pm25">--</span> · Pollen <span id="pollen">--</span></div>
</div>
<div class="stat-card sun-tile">
<div class="stat-label">Sun &amp; Moon</div>
<div class="sun-timeline-wrap">
<div class="sun-endpoint"><span class="sun-ep-icon">🌅</span><span id="sunrise-val" class="sun-ep-time">--</span></div>
<div class="sun-track-wrap"><div class="sun-track"><div class="sun-progress" id="sun-progress"></div><div class="sun-dot" id="sun-dot">☀️</div></div></div>
<div class="sun-endpoint"><span class="sun-ep-icon">🌇</span><span id="sunset-val" class="sun-ep-time">--</span></div>
</div>
<div class="moon-row"><span class="moon-emoji">🌙</span><span id="moon-phase-val">--</span><span class="daylight-info" id="daylight-info">--</span></div>
</div>
</div>
<details class="details-drawer discussion-drawer" id="discussion-drawer"> <details class="details-drawer discussion-drawer" id="discussion-drawer">
<summary class="drawer-summary"> <summary class="drawer-summary">
Forecaster's Discussion Forecaster's Discussion
@@ -242,68 +225,6 @@
</div> </div>
</details> </details>
<!-- ── MY STATION (Ambient Weather) ── -->
<details class="details-drawer station-drawer" id="station-drawer" open>
<summary class="drawer-summary">
My Station
<span class="station-drawer-peek" id="station-drawer-peek"></span>
<span class="drawer-chevron">›</span>
</summary>
<div class="drawer-body" id="station-body">
<div class="station-loading" id="station-loading">Loading station data…</div>
<div id="station-content" class="hidden">
<div class="station-name-row">
<span class="station-name" id="station-name"></span>
<span class="station-updated" id="station-updated"></span>
</div>
<div class="card-group drawer-card-group">
<div class="stat-card">
<div class="stat-label">Outdoor Temp</div>
<div class="stat-val"><span id="st-temp">--</span>°<span id="st-temp-unit">F</span></div>
<div class="stat-sub" id="st-feels"></div>
</div>
<div class="stat-card">
<div class="stat-label">Humidity</div>
<div class="stat-val"><span id="st-hum">--</span><span class="stat-unit">%</span></div>
<div class="stat-sub" id="st-dew"></div>
</div>
<div class="stat-card">
<div class="stat-label">Wind</div>
<div class="stat-val"><span id="st-wind">--</span> <span class="stat-unit" id="st-wind-unit">mph</span></div>
<div class="stat-sub" id="st-wind-dir"></div>
</div>
<div class="stat-card">
<div class="stat-label">Gusts</div>
<div class="stat-val"><span id="st-gust">--</span> <span class="stat-unit" id="st-gust-unit">mph</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Rain Today</div>
<div class="stat-val"><span id="st-rain-day">--</span> <span class="stat-unit" id="st-rain-unit">in</span></div>
<div class="stat-sub" id="st-rain-rate"></div>
</div>
<div class="stat-card">
<div class="stat-label">Pressure</div>
<div class="stat-val"><span id="st-pressure">--</span> <span class="stat-unit">inHg</span></div>
</div>
<div class="stat-card" id="st-uv-card">
<div class="stat-label">UV Index</div>
<div class="stat-val"><span id="st-uv">--</span></div>
</div>
<div class="stat-card" id="st-solar-card">
<div class="stat-label">Solar Rad.</div>
<div class="stat-val"><span id="st-solar">--</span> <span class="stat-unit">W/m²</span></div>
</div>
<div class="stat-card hidden" id="st-indoor-card">
<div class="stat-label">Indoor Temp</div>
<div class="stat-val"><span id="st-tempin">--</span>°<span id="st-tempin-unit">F</span></div>
<div class="stat-sub" id="st-humin"></div>
</div>
</div>
</div>
<div id="station-error" class="station-error hidden"></div>
</div>
</details>
<div class="footer-note"> <div class="footer-note">
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> · Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> · Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
@@ -313,6 +234,11 @@
</div> </div>
<!-- ── TAB: UNIFIED MAP ── --> <!-- ── TAB: UNIFIED MAP ── -->
<!-- ── TAB: STATION (live personal weather station) ── -->
<div id="tab-station" class="tab-panel hidden">
<div id="station-live" class="sl"></div>
</div>
<div id="tab-unified-map" class="tab-panel hidden"> <div id="tab-unified-map" class="tab-panel hidden">
<div class="unified-map-wrap"> <div class="unified-map-wrap">
<!-- Map Controls --> <!-- Map Controls -->
@@ -492,7 +418,7 @@
<div class="settings-section-label">Station</div> <div class="settings-section-label">Station</div>
<div class="settings-nav-links"> <div class="settings-nav-links">
<a href="/conditions" class="settings-nav-link">Live Conditions</a> <a href="/?tab=station" class="settings-nav-link">Live Station</a>
<a href="/dashboard" class="settings-nav-link">Dashboard</a> <a href="/dashboard" class="settings-nav-link">Dashboard</a>
</div> </div>
@@ -1336,6 +1262,10 @@
<span class="bottom-icon">⛅</span> <span class="bottom-icon">⛅</span>
<span class="bottom-label">Now</span> <span class="bottom-label">Now</span>
</button> </button>
<button class="bottom-tab" data-tab="station" type="button">
<span class="bottom-icon">📡</span>
<span class="bottom-label">Station</span>
</button>
<button class="bottom-tab" data-tab="unified-map" type="button"> <button class="bottom-tab" data-tab="unified-map" type="button">
<span class="bottom-icon">🗺️</span> <span class="bottom-icon">🗺️</span>
<span class="bottom-label">Map</span> <span class="bottom-label">Map</span>
@@ -1345,18 +1275,12 @@
<span class="bottom-label">Alerts</span> <span class="bottom-label">Alerts</span>
<span class="alert-badge hidden" id="alert-badge-bottom"></span> <span class="alert-badge hidden" id="alert-badge-bottom"></span>
</button> </button>
<button class="bottom-tab" data-tab="learn" type="button">
<span class="bottom-icon">📘</span>
<span class="bottom-label">Learn</span>
</button>
<button class="bottom-tab" data-tab="settings" type="button">
<span class="bottom-icon">⚙️</span>
<span class="bottom-label">Settings</span>
</button>
</nav> </nav>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="station-live.js"></script>
<script src="app-ui.js"></script>
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js'); navigator.serviceWorker.register('/sw.js');
+9 -2
View File
@@ -6,8 +6,8 @@
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone"], "display_override": ["window-controls-overlay", "standalone"],
"background_color": "#191510", "background_color": "#070c14",
"theme_color": "#191510", "theme_color": "#070c14",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"categories": ["weather", "utilities"], "categories": ["weather", "utilities"],
"icons": [ "icons": [
@@ -43,6 +43,13 @@
"url": "/?tab=conditions", "url": "/?tab=conditions",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
}, },
{
"name": "My Station",
"short_name": "Station",
"description": "Live readings from your weather station",
"url": "/?tab=station",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
},
{ {
"name": "Radar Map", "name": "Radar Map",
"short_name": "Map", "short_name": "Map",
+77
View File
@@ -0,0 +1,77 @@
/* Station tab — the former /conditions page look, scoped so it can't touch the rest of the app */
.sl {
--sl-bg: #edf2f7; --sl-surface: #ffffff; --sl-card: #f4f7fb; --sl-border: #ccd6e2;
--sl-text: #0d1b2a; --sl-muted: #5a7089; --sl-accent: #2563eb;
--sl-dot: rgba(37,99,235,0.065); --sl-live: #16a34a; --sl-north: #dc2626;
font-family: 'Barlow', system-ui, sans-serif; color: var(--sl-text);
max-width: 720px; margin: 0 auto; padding: 0.25rem 0 2.5rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .sl {
--sl-bg: #070c14; --sl-surface: #0d1720; --sl-card: #101c29; --sl-border: #192e44;
--sl-text: #dce6f0; --sl-muted: #6f8aa3; --sl-accent: #60a5fa;
--sl-dot: rgba(96,165,250,0.07); --sl-live: #4ade80; --sl-north: #f87171;
}
}
:root[data-theme="dark"] .sl {
--sl-bg: #070c14; --sl-surface: #0d1720; --sl-card: #101c29; --sl-border: #192e44;
--sl-text: #dce6f0; --sl-muted: #6f8aa3; --sl-accent: #60a5fa;
--sl-dot: rgba(96,165,250,0.07); --sl-live: #4ade80; --sl-north: #f87171;
}
.sl *, .sl *::before, .sl *::after { box-sizing: border-box; }
/* Status bar */
.sl-bar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.7rem 1rem; margin-bottom: 0.9rem;
background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 14px; }
.sl-name { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sl-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sl-live { display: flex; align-items: center; gap: 0.45rem; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sl-live); white-space: nowrap; }
.sl-live.stale { color: var(--sl-muted); }
.sl-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; animation: sl-pulse 2.4s ease-in-out infinite; }
.sl-live.stale .sl-dot { animation: none; opacity: .5; }
@keyframes sl-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }
.sl-time { font-size: 0.78rem; color: var(--sl-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Hero */
.sl-hero { position: relative; background: var(--sl-surface); padding: 3rem 1.25rem 2.75rem; text-align: center; overflow: hidden; border: 1px solid var(--sl-border); border-radius: 18px; }
.sl-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(circle, var(--sl-dot) 1.5px, transparent 1.5px); background-size: 28px 28px; pointer-events: none; }
.sl-hero-sweep { position: absolute; top: 50%; left: 50%; width: 320px; height: 320px; margin: -160px 0 0 -160px; border-radius: 50%;
background: conic-gradient(from 0deg, transparent 78%, var(--sl-accent) 100%); opacity: .18; animation: sl-sweep 14s linear infinite; pointer-events: none; }
.sl-hero-ring, .sl-hero-ring2 { position: absolute; top: 50%; left: 50%; border-radius: 50%; border: 1px solid var(--sl-border); pointer-events: none; }
.sl-hero-ring { width: 400px; height: 400px; margin: -200px 0 0 -200px; }
.sl-hero-ring2 { width: 240px; height: 240px; margin: -120px 0 0 -120px; }
@keyframes sl-sweep { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .sl-hero-sweep, .sl-dot { animation: none; } }
.sl-hero-body { position: relative; z-index: 1; }
.sl-temp-row { display: inline-flex; align-items: flex-start; line-height: 1; font-family: 'Barlow Condensed', sans-serif; font-weight: 800; letter-spacing: -0.03em; }
.sl-temp-num { font-size: clamp(5.5rem, 22vw, 9rem); line-height: 1; transition: color .5s ease; }
.sl-temp-deg { font-size: clamp(1.8rem, 7vw, 3rem); margin-top: .15em; margin-left: .05em; opacity: .6; transition: color .5s ease; }
.sl-hero-sub { margin-top: .45rem; font-size: .95rem; color: var(--sl-muted); }
.sl-pill { display: inline-flex; align-items: center; gap: .35rem; margin-top: .85rem; padding: .3rem 1rem; background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 999px; font-size: .82rem; font-weight: 500; color: var(--sl-muted); }
/* Sections */
.sl-section { margin-top: 1.4rem; }
.sl-label { font-size: .62rem; font-weight: 600; letter-spacing: .11em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .6rem; }
.sl-row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: .7rem; }
.sl-row4 { display: grid; grid-template-columns: repeat(2, 1fr); gap: .7rem; }
.sl-row2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: .7rem; }
@media (min-width: 480px) { .sl-row4 { grid-template-columns: repeat(4, 1fr); } }
.sl-card { background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px; padding: 1rem 1.1rem .9rem; min-width: 0; }
.sl-card-label { font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .3rem; }
.sl-card-val { font-family: 'Barlow Condensed', sans-serif; font-size: 2rem; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.sl-card-unit { font-size: .68rem; color: var(--sl-muted); margin-top: .2rem; }
/* Wind */
.sl-wind { background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px; padding: 1.2rem; display: flex; align-items: center; gap: 1.5rem; }
.sl-compass { flex-shrink: 0; display: block; }
.sl-wind-grid { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1.25rem; }
.sl-wind-label { font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .2rem; }
.sl-wind-val { font-family: 'Barlow Condensed', sans-serif; font-size: 1.75rem; font-weight: 700; line-height: 1; }
.sl-wind-unit { font-size: .65rem; color: var(--sl-muted); }
@media (max-width: 460px) { .sl-wind { flex-direction: column; } }
.sl-foot { text-align: center; font-size: .7rem; color: var(--sl-muted); margin-top: 1.5rem; }
.sl-empty { text-align: center; padding: 3rem 1.25rem; background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 18px; color: var(--sl-muted); }
.sl-empty b { display: block; font-family: 'Barlow Condensed', sans-serif; font-size: 1.6rem; color: var(--sl-text); margin-bottom: .4rem; }
.sl-empty button { margin-top: 1rem; font: inherit; font-weight: 600; padding: .6rem 1.2rem; min-height: 44px; border-radius: 10px; border: 1px solid var(--sl-accent); background: transparent; color: var(--sl-accent); cursor: pointer; }
.sl-link { display: inline-flex; align-items: center; gap: .3rem; font-size: .78rem; font-weight: 600; color: var(--sl-muted); text-decoration: none; padding: .32rem .75rem; border: 1px solid var(--sl-border); border-radius: 8px; }
.sl-link:hover { color: var(--sl-accent); border-color: var(--sl-accent); }
+243
View File
@@ -0,0 +1,243 @@
/* Station tab: live readings from the personal weather station, in the former /conditions look.
app.js calls renderStationTab(device) whenever new station data arrives. */
(() => {
'use strict';
const root = document.getElementById('station-live');
if (!root) return;
const cardinal = (deg) => (deg == null ? '—' : ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(+deg / 22.5) % 16]);
const metric = () => typeof state !== 'undefined' && state.units !== 'us';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const fTemp = (f) => (num(f) == null ? '—' : String(Math.round(metric() ? (f - 32) * 5 / 9 : f)));
const fSpeed = (m) => (num(m) == null ? '—' : String(Math.round(metric() ? m * 1.60934 : m)));
const fRain = (i) => (num(i) == null ? '—' : metric() ? (i * 25.4).toFixed(1) : i.toFixed(2));
const fPres = (i) => (num(i) == null ? '—' : metric() ? String(Math.round(i * 33.8639)) : i.toFixed(2));
const units = () => ({ t: metric() ? '°C' : '°F', s: metric() ? 'km/h' : 'mph', r: metric() ? 'mm' : 'in', p: metric() ? 'hPa' : 'inHg' });
function tempColor(f) {
if (f == null) return 'var(--sl-text)';
if (f <= 32) return '#93c5fd';
if (f <= 50) return '#60a5fa';
if (f <= 65) return 'var(--sl-text)';
if (f <= 80) return '#f59e0b';
if (f <= 90) return '#f97316';
return '#ef4444';
}
function uvInfo(uv) {
if (uv == null) return ['', 'var(--sl-text)'];
if (uv < 3) return ['Low', '#4ade80'];
if (uv < 6) return ['Moderate', '#facc15'];
if (uv < 8) return ['High', '#f97316'];
if (uv < 11) return ['Very High', '#ef4444'];
return ['Extreme', '#d946ef'];
}
function conditionText(d) {
if (!d) return '—';
if (d.rainratein > 0.05) return '🌧 Raining';
const s = d.solarradiation;
if (s != null) {
if (s > 700) return '☀️ Sunny';
if (s > 250) return '🌤 Partly Cloudy';
if (s > 60) return '⛅ Mostly Cloudy';
if (s > 5) return '☁️ Overcast';
}
return '🌙 Night';
}
const SHELL = `
<div class="sl-bar">
<span class="sl-name" id="sl-name">My Station</span>
<span class="sl-live" id="sl-live"><span class="sl-dot"></span><span id="sl-live-text">Live</span></span>
<span><span class="sl-time" id="sl-time"></span></span>
</div>
<section class="sl-hero">
<div class="sl-hero-dots"></div><div class="sl-hero-sweep"></div><div class="sl-hero-ring"></div><div class="sl-hero-ring2"></div>
<div class="sl-hero-body">
<div class="sl-temp-row"><div class="sl-temp-num" id="sl-temp">—</div><div class="sl-temp-deg" id="sl-deg"></div></div>
<div class="sl-hero-sub" id="sl-sub"></div>
<div><span class="sl-pill" id="sl-pill">Loading…</span></div>
</div>
</section>
<div class="sl-section"><div class="sl-label">Atmosphere</div>
<div class="sl-row3">
<div class="sl-card"><div class="sl-card-label">Humidity</div><div class="sl-card-val" id="sl-hum">—</div><div class="sl-card-unit">%</div></div>
<div class="sl-card"><div class="sl-card-label">Dew Point</div><div class="sl-card-val" id="sl-dew">—</div><div class="sl-card-unit" id="sl-dew-u">°F</div></div>
<div class="sl-card"><div class="sl-card-label">Pressure</div><div class="sl-card-val" id="sl-pres">—</div><div class="sl-card-unit" id="sl-pres-u">inHg</div></div>
</div>
</div>
<div class="sl-section"><div class="sl-label">Wind</div>
<div class="sl-wind">
<canvas class="sl-compass" id="sl-compass" width="152" height="152" style="width:152px;height:152px"></canvas>
<div class="sl-wind-grid">
<div><div class="sl-wind-label">Speed</div><div class="sl-wind-val" id="sl-ws">—</div><div class="sl-wind-unit sl-su">mph</div></div>
<div><div class="sl-wind-label">Gust</div><div class="sl-wind-val" id="sl-wg">—</div><div class="sl-wind-unit sl-su">mph</div></div>
<div><div class="sl-wind-label">From</div><div class="sl-wind-val" id="sl-wd">—</div><div class="sl-wind-unit" id="sl-wdeg"></div></div>
<div><div class="sl-wind-label">Max Today</div><div class="sl-wind-val" id="sl-wm">—</div><div class="sl-wind-unit sl-su">mph</div></div>
</div>
</div>
</div>
<div class="sl-section"><div class="sl-label">Precipitation &amp; Solar</div>
<div class="sl-row4">
<div class="sl-card"><div class="sl-card-label">Rain Today</div><div class="sl-card-val" id="sl-rain">—</div><div class="sl-card-unit sl-ru">in</div></div>
<div class="sl-card"><div class="sl-card-label">Rain Rate</div><div class="sl-card-val" id="sl-rate">—</div><div class="sl-card-unit sl-rru">in / hr</div></div>
<div class="sl-card"><div class="sl-card-label">UV Index</div><div class="sl-card-val" id="sl-uv">—</div><div class="sl-card-unit" id="sl-uvl"></div></div>
<div class="sl-card"><div class="sl-card-label">Solar Rad.</div><div class="sl-card-val" id="sl-solar">—</div><div class="sl-card-unit">W/m²</div></div>
</div>
</div>
<div class="sl-section" id="sl-indoor-sec" hidden><div class="sl-label">Indoor</div>
<div class="sl-row2">
<div class="sl-card"><div class="sl-card-label">Temperature</div><div class="sl-card-val" id="sl-tin">—</div><div class="sl-card-unit sl-tu">°F</div></div>
<div class="sl-card"><div class="sl-card-label">Humidity</div><div class="sl-card-val" id="sl-hin">—</div><div class="sl-card-unit">%</div></div>
</div>
</div>
<div class="sl-foot"><span id="sl-foot">Refreshes automatically every minute</span> · <a class="sl-link" href="/dashboard">Dashboard →</a></div>`;
const EMPTY = (title, text) => `
<div class="sl-empty"><b>${title}</b>${text}<div><button type="button" id="sl-goto-settings">Open Settings</button></div></div>`;
const $ = (id) => document.getElementById(id);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
const setAll = (sel, v) => root.querySelectorAll(sel).forEach((el) => { el.textContent = v; });
let shellReady = false;
let lastDir = null;
function ensureShell() {
if (shellReady) return;
root.innerHTML = SHELL;
shellReady = true;
drawCompass(null, null);
}
function showMessage(title, text) {
shellReady = false;
root.innerHTML = EMPTY(title, text);
$('sl-goto-settings')?.addEventListener('click', () => { if (typeof activateTab === 'function') activateTab('settings'); });
}
function drawCompass(dir, speedText) {
const canvas = $('sl-compass');
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const size = 152;
canvas.width = size * dpr; canvas.height = size * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cs = getComputedStyle(root);
const v = (n, f) => cs.getPropertyValue(n).trim() || f;
const cCard = v('--sl-surface', '#fff'), cRing = v('--sl-border', '#ccd6e2'), cLabel = v('--sl-muted', '#5a7089');
const cAccent = v('--sl-accent', '#2563eb'), cNorth = v('--sl-north', '#dc2626');
const cx = size / 2, cy = size / 2, r = size / 2 - 7;
ctx.fillStyle = cCard; ctx.beginPath(); ctx.arc(cx, cy, r + 5, 0, Math.PI * 2); ctx.fill();
[1 / 3, 2 / 3, 1].forEach((f) => { ctx.beginPath(); ctx.arc(cx, cy, r * f, 0, Math.PI * 2); ctx.strokeStyle = cRing; ctx.lineWidth = 0.7; ctx.stroke(); });
for (let i = 0; i < 8; i++) {
const a = ((i * 45 - 90) * Math.PI) / 180, major = i % 2 === 0, len = major ? 9 : 5;
ctx.beginPath(); ctx.moveTo(cx + r * Math.cos(a), cy + r * Math.sin(a));
ctx.lineTo(cx + (r - len) * Math.cos(a), cy + (r - len) * Math.sin(a));
ctx.strokeStyle = major ? cLabel : cRing; ctx.lineWidth = major ? 1.5 : 0.7; ctx.stroke();
}
[['N', -90, cNorth], ['E', 0, cLabel], ['S', 90, cLabel], ['W', 180, cLabel]].forEach(([l, deg, color]) => {
const a = (deg * Math.PI) / 180;
ctx.font = `700 ${l === 'N' ? 11 : 10}px 'Barlow', sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = color;
ctx.fillText(l, cx + (r - 18) * Math.cos(a), cy + (r - 18) * Math.sin(a));
});
if (dir != null) {
const a = ((+dir - 90) * Math.PI) / 180, tip = r * 0.6;
const tx = cx + tip * Math.cos(a), ty = cy + tip * Math.sin(a);
ctx.strokeStyle = cAccent; ctx.lineWidth = 2; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty); ctx.stroke();
const ha = Math.atan2(ty - cy, tx - cx), hl = 13;
ctx.fillStyle = cAccent; ctx.beginPath(); ctx.moveTo(tx, ty);
ctx.lineTo(tx - hl * Math.cos(ha - 0.38), ty - hl * Math.sin(ha - 0.38));
ctx.lineTo(tx - hl * 0.55 * Math.cos(ha), ty - hl * 0.55 * Math.sin(ha));
ctx.lineTo(tx - hl * Math.cos(ha + 0.38), ty - hl * Math.sin(ha + 0.38));
ctx.closePath(); ctx.fill();
const ta = a + Math.PI;
ctx.globalAlpha = 0.3; ctx.lineWidth = 1.5; ctx.strokeStyle = cAccent;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + r * 0.22 * Math.cos(ta), cy + r * 0.22 * Math.sin(ta)); ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = cAccent; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
}
}
window.renderStationTab = function renderStationTab(device) {
const d = device?.lastData;
if (!d) return;
ensureShell();
const u = units();
const name = (typeof state !== 'undefined' && state.stationName) || device.info?.name || 'My Station';
set('sl-name', name);
const fresh = d.dateutc && Date.now() - d.dateutc < 15 * 60 * 1000;
const live = $('sl-live');
live.classList.toggle('stale', !fresh);
set('sl-live-text', fresh ? 'Live' : 'Offline');
if (d.dateutc) {
const t = new Date(d.dateutc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
set('sl-time', t);
set('sl-foot', `Updated ${t} · refreshes automatically`);
}
const tempEl = $('sl-temp'), degEl = $('sl-deg');
tempEl.textContent = fTemp(d.tempf);
degEl.textContent = d.tempf != null ? u.t : '';
const col = tempColor(d.tempf);
tempEl.style.color = col; degEl.style.color = col;
const fl = d.feelsLike ?? d.tempf;
set('sl-sub', fl != null ? `Feels like ${fTemp(fl)}${u.t}` : '');
set('sl-pill', fresh ? conditionText(d) : 'Station offline');
set('sl-hum', d.humidity != null ? String(Math.round(d.humidity)) : '—');
set('sl-dew', fTemp(d.dewPoint)); set('sl-dew-u', u.t);
set('sl-pres', fPres(d.baromrelin)); set('sl-pres-u', u.p);
set('sl-ws', fSpeed(d.windspeedmph)); set('sl-wg', fSpeed(d.windgustmph)); set('sl-wm', fSpeed(d.maxdailygust));
set('sl-wd', cardinal(d.winddir)); set('sl-wdeg', d.winddir != null ? Math.round(+d.winddir) + '°' : '');
setAll('.sl-su', u.s);
lastDir = d.winddir ?? null;
drawCompass(lastDir);
set('sl-rain', fRain(d.dailyrainin)); set('sl-rate', fRain(d.rainratein));
setAll('.sl-ru', u.r); setAll('.sl-rru', `${u.r} / hr`);
const [uvLabel, uvColor] = uvInfo(d.uv);
set('sl-uv', d.uv != null ? String(Math.round(d.uv)) : '—');
$('sl-uv').style.color = uvColor; set('sl-uvl', uvLabel);
set('sl-solar', d.solarradiation != null ? String(Math.round(d.solarradiation)) : '—');
const sec = $('sl-indoor-sec');
if (d.tempinf != null) {
sec.hidden = false;
set('sl-tin', fTemp(d.tempinf)); setAll('.sl-tu', u.t);
set('sl-hin', d.humidityin != null ? String(Math.round(d.humidityin)) : '—');
} else sec.hidden = true;
};
// Called by app.js when the station can't be reached / isn't configured
window.stationTabMessage = function (kind, detail) {
if (shellReady) {
// Keep showing the last readings but flag them as stale
const live = $('sl-live'); if (live) { live.classList.add('stale'); set('sl-live-text', 'Offline'); }
return;
}
if (kind === 'unconfigured') {
showMessage('No station connected', 'Add your Ambient Weather API keys in Settings to see live readings from your own station here.');
} else {
showMessage('Station unavailable', detail || 'Could not reach your station right now. It will keep retrying.');
}
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => shellReady && drawCompass(lastDir));
new MutationObserver(() => shellReady && drawCompass(lastDir)).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
root.innerHTML = '<div class="sl-empty"><b>Loading…</b>Fetching your station readings.</div>';
})();
+28 -42
View File
@@ -1,15 +1,15 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--ink: #1a1a1a; --ink: #0d1b2a;
--ink-mid: #3d3730; --ink-mid: #2b3f55;
--ink-light: #5f5649; --ink-light: #5a7089;
--paper: #f5f2ec; --paper: #edf2f7;
--paper-dark: #ece7db; --paper-dark: #ffffff;
--paper-darker: #ddd6c7; --paper-darker: #e3eaf2;
--border: #b9ae9b; --border: #ccd6e2;
--accent: #c25a2a; --accent: #2563eb;
--cool: #2a6b8a; --cool: #0e7490;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
--serif: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; --serif: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
--card-bg: var(--paper-dark); --card-bg: var(--paper-dark);
@@ -25,28 +25,28 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { :root:not([data-theme="light"]) {
--ink: #ece7dc; --ink: #dce6f0;
--ink-mid: #d2c8b1; --ink-mid: #b7c6d6;
--ink-light: #beb399; --ink-light: #8aa2b8;
--paper: #191510; --paper: #070c14;
--paper-dark: #201c16; --paper-dark: #0d1720;
--paper-darker: #2b2620; --paper-darker: #101c29;
--border: #3a342a; --border: #22405c;
--accent: #e07848; --accent: #60a5fa;
--cool: #5ab0d8; --cool: #38bdf8;
} }
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--ink: #ece7dc; --ink: #dce6f0;
--ink-mid: #d2c8b1; --ink-mid: #b7c6d6;
--ink-light: #beb399; --ink-light: #8aa2b8;
--paper: #191510; --paper: #070c14;
--paper-dark: #201c16; --paper-dark: #0d1720;
--paper-darker: #2b2620; --paper-darker: #101c29;
--border: #3a342a; --border: #22405c;
--accent: #e07848; --accent: #60a5fa;
--cool: #5ab0d8; --cool: #38bdf8;
} }
html, body { html, body {
@@ -366,7 +366,7 @@ a:hover { text-decoration: underline; }
.map-location-dot { .map-location-dot {
width: 12px; width: 12px;
height: 12px; height: 12px;
background: #e07848; background: #60a5fa;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 1px 4px rgba(0,0,0,0.5); box-shadow: 0 1px 4px rgba(0,0,0,0.5);
@@ -2532,20 +2532,6 @@ body.map-fs #unified-map {
/* ── My Station (Ambient Weather) ──────────────────────────────────────────── */ /* ── My Station (Ambient Weather) ──────────────────────────────────────────── */
.station-drawer { margin-top: 1rem; }
.station-drawer-peek { font-size: 0.8rem; color: var(--ink-light); margin-left: 0.5rem; font-weight: 400; }
.station-loading { color: var(--ink-light); font-size: 0.9rem; padding: 0.5rem 0; }
.station-error { color: #e57373; font-size: 0.88rem; padding: 0.4rem 0; }
.station-name-row {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.station-name { font-weight: 600; font-size: 0.95rem; }
.station-updated { font-size: 0.75rem; color: var(--ink-light); }
.station-badge { .station-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+5 -1
View File
@@ -4,13 +4,17 @@ self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting(); if (event.data === 'skipWaiting') self.skipWaiting();
}); });
const CACHE = 'weather-v7'; const CACHE = 'weather-v9';
// App shell cached on install for offline resilience. // App shell cached on install for offline resilience.
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/app.js', '/app.js',
'/style.css', '/style.css',
'/station-live.css',
'/station-live.js',
'/app-ui.css',
'/app-ui.js',
'/manifest.json', '/manifest.json',
'/icons/icon.svg' '/icons/icon.svg'
]; ];
+628 -159
View File
File diff suppressed because it is too large. Load diff