Compare commits

...
35 Commits
Author SHA1 Message Date
Nate EmmertandClaude Sonnet 5.5 27106a2eab Dashboard Records tab: plain-language records
Publish Container / publish (push) Successful in 3m8s
Replaces the collapsed panel of look-alike cards with a guided view: an intro explaining what a record is
(and that records are 'so far' with a 12-month coverage strip), cards grouped by theme with a plain
explanation, date/time, how long ago, descriptors (gust/UV/rain intensity), today-vs-record and
next-closest days, NEW badges for records set in the last 14 days, one month-by-month table with
temperature-range and rain bars (latest 12, show all), and year / same-month tables only when
there is more than a year of data. Removes duplicate cards and the redundant Monthly Summary panel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:56:01 -04:00
Nate EmmertandClaude Sonnet 5.5 9515d1af00 Dashboard coverage: count 5-minute slots with data, not readings vs the recent interval
Publish Container / publish (push) Successful in 2m29s
Coverage divided each day's reading count by 1440/median-recent-interval, so days logged at a
slower pace (100 s, 120 s) showed as 50-60% complete once uploads sped up to 60 s. Each day now
records which of its 288 five-minute slots have a reading (backfilled from raw files on startup),
so coverage is independent of upload interval. Older days without slot data fall back to the old maths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:46:44 -04:00
Nate EmmertandClaude Sonnet 5.5 b8ce8ce149 Account page: add Settings link
Publish Container / publish (push) Successful in 2m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 07:35:33 -04:00
Nate EmmertandClaude Sonnet 5.5 dee245ea27 Dashboard: always show Settings link; non-admins get an explanation of their role
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 07:34:32 -04:00
Nate EmmertandClaude Sonnet 5.5 2efc945192 Radar: switch to RainViewer (IEM blocks heavy tile use), cleaner Map tab
Publish Container / publish (push) Successful in 2m40s
- /api/radar/times serves RainViewer frames (cached 2 min), IEM NEXRAD only as fallback;
  sources honour maxNativeZoom so tiles overzoom instead of going blank
- Map tab opens with radar + location dot only; wind/AQI/alerts/temp/sun/lightning are opt-in,
  readout chips follow their toggles
- Bolder location dot
- Satellite (GIBS) layer: new storage key, never persisted by storm taps, radar always draws above it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:38:00 -04:00
Nate EmmertandClaude Sonnet 5.5 13985344ed Storm cards show live GOES infrared satellite; replace the dead RainViewer satellite toggle
Publish Container / publish (push) Successful in 2m19s
RainViewer's satellite feed is empty, so the Clouds toggle never drew anything and ground
radar doesn't reach open ocean. Use NASA GIBS GOES-West/East infrared tiles instead,
auto-enabled when flying to a storm, default off otherwise.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:01:03 -04:00
Nate EmmertandClaude Sonnet 5.5 a185d10e1c Fix storm cards not flying to the storm: stop waiting on the one-shot map 'load' event
Publish Container / publish (push) Successful in 2m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:52:59 -04:00
Nate EmmertandClaude Sonnet 5.5 fda8dca435 Performance: stop iPhone freezes (blur over live map, runaway animation, eager charts)
Publish Container / publish (push) Successful in 2m57s
Main app
- No backdrop-filter over the live map, animated sky, or sticky headings (it re-blurs every
  frame; on iPhone that is a classic stutter/lock-up); top/bottom bars keep a light 8px blur
- Sky effect only draws while the hero is on screen and the Now tab is active, ~30fps cap,
  fewer particles on phones; fixed a stale animation-frame id that could stop restarts

Dashboard
- Charts are built only when the Charts tab is opened (was ~11 charts on every page load),
  and the 2-minute refresh no longer rebuilds them in the background
- On phones: max 360 points per chart (was 720), no entry animation, device pixel ratio capped at 2
- Lighter header blur, none on small buttons/cards

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:29:14 -04:00
Nate EmmertandClaude Sonnet 5.5 dde59e3865 Station tab: explain what the station is
Publish Container / publish (push) Successful in 2m13s
- 'What is this station?' card: a personal weather station measuring real conditions on site
  (not a forecast), how it differs from the Now tab, update cadence, what Live/Offline mean
- Lists the instruments this station actually reports (temp/humidity, wind, rain, pressure,
  sun, indoor), using the station's real name
- Open for new visitors, remembers once closed; empty state and Now-tab badge/tab hover explain it too

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:16:33 -04:00
Nate EmmertandClaude Sonnet 5.5 eaff2f55c8 Location: say why it failed, retry once, show permission status
Publish Container / publish (push) Successful in 2m15s
The ⌖ button reported 'Location access denied' for every failure, including timeouts and
no-GPS-fix errors that have nothing to do with permission.

- Specific messages for denied / no fix / timeout, with the exact iPhone steps (Safari tab vs
  installed web app) and the error code for diagnosis
- One patient high-accuracy retry for slow or cold GPS fixes (a real denial is not retried)
- Live permission status line under the search row (allowed / not asked / blocked)
- Mentions Safari content blockers as a possible cause

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:07:19 -04:00
Nate EmmertandClaude Sonnet 5.5 0cbebfef2a Cache-proof asset URLs and inline iOS touch icon
Publish Container / publish (push) Successful in 2m42s
A reverse proxy in front of the app serves stale copies of static files for hours (live app.js and
sw.js were old next to new HTML). iOS also showed a letter tile (N / S) because its separate
icon download failed.

- HTML pages are served through a versioner: every local script/stylesheet/icon/manifest gets a
  content-hash query (app.js?v=…), so any change is a URL no proxy or phone has cached
- Service worker URL versioned too; SW offline lookups tolerate the query string; cache v15
- The 180px apple-touch-icon is embedded in each page as a data: URI, so the Home Screen icon
  no longer depends on a second network request

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:33:40 -04:00
Nate EmmertandClaude Sonnet 5.5 f21c91527c Serve PWA icons from fresh /icons/v2/ URLs with every iOS size
Publish Container / publish (push) Successful in 2m43s
iOS showed a letter tile ('N') when adding to the Home Screen. The live files were fine, but a
reverse proxy in front of the app serves static files with a long cache lifetime (live icon.svg
and sw.js were stale) and iOS remembers a failed icon per URL.

- New versioned URLs no proxy/phone has cached; old paths kept for existing installs
- apple-touch-icon declared at 180/167/152/120 (opaque 8-bit RGB PNGs) for app and dashboard
- 180px icon also listed in both manifests; notification icons and root fallbacks use v2
- Service worker cache bumped

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:22:05 -04:00
Nate EmmertandClaude Sonnet 5.5 19479daece Dashboard pages: iOS icon + Home Screen title everywhere; root icon fallbacks
Publish Container / publish (push) Successful in 2m11s
- setup/recover/settings pages now declare the dashboard touch icon and 'Station' title
- /apple-touch-icon.png, /apple-touch-icon-precomposed.png and /favicon.ico serve real icons
  (iOS falls back to these when a page declares none; they used to 404/204)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:15:19 -04:00
Nate EmmertandClaude Sonnet 5.5 023e6cb03c New PWA icons: slate/blue weather icon, distinct dashboard icon, safe-zone maskable
Publish Container / publish (push) Successful in 1m59s
- Redrawn sun-and-cloud icon in the current palette; artwork sits inside the maskable safe zone
  (the old one was clipped by round/squircle masks and used the old warm colours)
- Separate 'any' and 'maskable' manifest entries; opaque iOS touch icons
- Station Dashboard gets its own chart icon so both installed apps are distinguishable
- Vector favicon + PNG fallback; icons verified served by both manifests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:13:01 -04:00
Nate EmmertandClaude Sonnet 5.5 72a4a3fe38 Alerts tab: fix hurricane/tropical storm bugs; rich alert cards matching the dashboard
Publish Container / publish (push) Successful in 2m44s
Hurricane / tropical storm fixes
- NHC intensity is in knots: show real mph (80 kt = 92 mph) instead of mislabelled 'mph'
- Real Saffir-Simpson category from wind (was always cat 1); subtropical, post-tropical and
  potential-cyclone types no longer shown as 'depression'; each storm shows movement
  (direction + mph, previously a non-existent field), distance from you, update time, advisory link
- Forecast track points coloured by their own forecast strength (not the current class)
- Map click handlers registered once (popups stacked on every refresh); overlays wait for the
  map to settle instead of silently not drawing while tiles load; stale renders superseded
- Subtropical/tropical advice added to alert guidance; escaped storm names

Alert cards (active alerts only)
- What it's for, area, window, time remaining, plain-language meaning, what to do, and a
  Full details expander (headline, text, instructions, severity/urgency/certainty, issuer)
- No longer drops alerts beyond 6 per severity group; expired alerts are filtered out
- Shared plain-language alert glossary (alert-info.js, 64 products incl. tropical and surge)
  now used by both the app and the dashboard history

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:00:41 -04:00
Nate EmmertandClaude Sonnet 5.5 8252017417 Map tab: full-bleed map with floating glass controls (Apple Weather style)
Publish Container / publish (push) Successful in 2m58s
- Map fills everything between the top bar and bottom nav; no page scroll on this tab
- Floating round controls (layers, my location, full screen), readout chips top-left,
  layers sheet beside the controls, weather summary + radar timeline floating at the bottom
- Safe-area aware in full-screen mode; zoom buttons tucked away on touch devices

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:48:13 -04:00
Nate EmmertandClaude Sonnet 5.5 4f47d67a7e Map tab: much bigger map with its own remembered view
Publish Container / publish (push) Successful in 2m12s
- Map fills the screen (was a 56vh strip); timeline and details sit below it
- No longer snaps back to the main-page radar's location/zoom on every refresh or tab switch;
  starts as a wider regional view, remembers pan/zoom, and recentres only when the location changes
- New 'My location' button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:43:04 -04:00
Nate EmmertandClaude Sonnet 5.5 acdcd6a2c5 Log official NWS observations for a reference ZIP and overlay them on the dashboard
Publish Container / publish (push) Successful in 2m33s
- Resolves ZIP (default 24550) to the nearest NWS observation station; logs every observation
  (normalised imperial values + raw METAR), back-fills ~7 days, snapshots the hourly forecast
  every 3h, and logs alerts for that point
- Chart overlay (temp, humidity/dew point, wind, pressure) with an NWS toggle in the History bar
- 'Official NWS observation' card on the Now tab comparing against your station
- Settings: change the reference ZIP and see logging status
- Exact-unit conversions for NWS values (verified against known conversions)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:41:02 -04:00
Nate EmmertandClaude Sonnet 5.5 5ed673bacc Install-as-app guide and iPhone safe-area support
Publish Container / publish (push) Successful in 2m51s
- Install banner (phones), Settings guide and dialog with iPhone/iPad and Android steps,
  native Install button where supported; steps also on the dashboard Account page
- apple-touch-icon now a PNG (iOS ignores SVG icons)
- Shared pwa-safe.css: dark cap behind the status bar/Dynamic Island in the installed app,
  inset-aware offline banner, pull-to-refresh, bottom bars, full-screen map, dialogs
- Landscape-aware content gutters; measured top-bar height; viewport-fit=cover on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:34:22 -04:00
Nate EmmertandClaude Sonnet 5.5 114386a1b2 Alert history: say what each alert is for, where, and when
Publish Container / publish (push) Successful in 3m33s
- Plain-language meaning for common NWS products, shown in the row and expanded view
- Details column: reason, area, severity, effective window; click for full text/instructions
- New alerts log area, severity, urgency, certainty and sender
- Older entries are filled in from NWS by alert id (cached in data/alert-details.json);
  alerts NWS no longer serves fall back to the built-in meaning

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:28:28 -04:00
Nate EmmertandClaude Sonnet 5.5 72d813bd61 Records: month and year records with the period as the headline
Publish Container / publish (push) Successful in 3m55s
- Wettest/driest/hottest/coldest/windiest month and wettest/hottest/coldest/windiest year,
  each naming the period (e.g. 'September 2026') and flagging partial periods
- Average-based records prefer complete periods; counterparts say what they're waiting for
- 'Records by calendar month (any year)' table with the date/year of each record

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:21:55 -04:00
Nate EmmertandClaude Sonnet 5.5 6e285bfef1 Show the first-recorded date on the All history button tooltip
Publish Container / publish (push) Successful in 2m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:17:46 -04:00
Nate EmmertandClaude Sonnet 5.5 00336c6ce1 Add 'All' history range to the dashboard and pressure tendency chart
Publish Container / publish (push) Successful in 2m45s
- /api/station/history?range=all returns everything on disk, bucketed (cached 60s)
- Year-aware axis labels for 1y/2y/3y/All
- Pressure tendency widens its change window to the bucket size on long ranges and says so

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:17:33 -04:00
Nate EmmertandClaude Sonnet 5.5 fc390b0bb1 Pressure tendency chart: own range and change-window controls
Publish Container / publish (push) Successful in 2m28s
Range: same as page / 6h / 24h / 2d / 7d / 30d. Change over: 1h / 3h / 6h / 12h / 24h.
Choices persist in localStorage.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:16:05 -04:00
Nate EmmertandClaude Sonnet 5.5 74198330d9 Fix mouse-wheel scrolling in the main app and the dashboard sun arc
Publish Container / publish (push) Successful in 2m55s
- overscroll-behavior moved from <body> to <html>: with html+body both overflow-x:hidden,
  body is a scroll container and the rule blocked wheel scrolling from reaching the page
- sunTimes returned the neighbouring day's sunrise/sunset (julian-day rounding), so midday
  looked like night; align results to the requested local day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:12:41 -04:00
Nate EmmertandClaude Sonnet 5.5 a9fc20cef4 Let the mouse wheel scroll the page over charts and the embedded radar
Publish Container / publish (push) Successful in 3m16s
Chart zoom now needs Ctrl+scroll (or pinch); the embedded radar uses cooperative gestures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 13:09:19 -04:00
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
49 changed files with 5188 additions and 1734 deletions

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
/* Shared plain-language alert helpers (used by the weather app and the station dashboard) */
(() => {
'use strict';
const GLOSSARY = {
'heat advisory': 'Dangerously hot conditions expected. Limit time outdoors, drink plenty of water, and check on children, older adults and pets.',
'excessive heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
'excessive heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
'extreme heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
'extreme heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
'severe thunderstorm watch': 'Storms capable of damaging wind or large hail are possible in the area. Stay alert and be ready to take shelter.',
'severe thunderstorm warning': 'A severe storm with damaging wind and/or large hail is happening or imminent. Move indoors away from windows.',
'tornado watch': 'Conditions are favorable for tornadoes. Stay alert and know where you would take shelter.',
'tornado warning': 'A tornado is occurring or imminent. Take shelter immediately in the lowest, most interior room.',
'special weather statement': 'A short notice about a significant but non-warning weather event, often a strong storm, gusty winds, dense fog or sudden changes.',
'flood watch': 'Flooding is possible. Be prepared to move to higher ground and avoid low-lying areas.',
'flood advisory': 'Minor flooding is occurring or expected. Avoid flooded roads and low spots.',
'flood warning': 'Flooding is happening or imminent. Move to higher ground and never drive through flood water.',
'flash flood watch': 'Flash flooding is possible. Be ready to move to higher ground quickly.',
'flash flood warning': 'Flash flooding is occurring or imminent. Move to higher ground now.',
'wind advisory': 'Sustained or gusty winds that can blow around unsecured objects and make driving difficult for high-profile vehicles.',
'high wind watch': 'Damaging winds are possible. Secure outdoor objects.',
'high wind warning': 'Damaging winds are occurring or imminent. Secure objects and avoid travel if possible.',
'freeze watch': 'A freeze is possible. Protect sensitive plants, pets and exposed pipes.',
'freeze warning': 'Temperatures at or below freezing are expected. Protect plants, pets and exposed pipes.',
'frost advisory': 'Frost is expected and can damage sensitive plants.',
'hard freeze warning': 'Severe freezing temperatures are expected. Protect plants, pets and pipes.',
'winter storm watch': 'Heavy snow and/or ice is possible. Prepare for hazardous travel.',
'winter storm warning': 'Heavy snow and/or ice is occurring or imminent. Travel may be dangerous or impossible.',
'winter weather advisory': 'Snow, sleet or freezing rain will cause slippery roads and travel difficulty.',
'ice storm warning': 'Significant ice accumulation is expected. Power outages and dangerous travel are likely.',
'blizzard warning': 'Heavy snow with strong winds and very low visibility. Avoid travel.',
'wind chill advisory': 'Dangerously cold wind chills are expected. Dress in layers and limit time outside.',
'wind chill warning': 'Life-threatening wind chills are expected. Frostbite can occur quickly.',
'dense fog advisory': 'Fog is reducing visibility to a quarter mile or less. Slow down and use low-beam headlights.',
'dense smoke advisory': 'Smoke is reducing visibility and air quality.',
'air quality alert': 'Air quality is unhealthy for some or all people. Limit prolonged outdoor exertion.',
'red flag warning': 'Dry, windy and warm conditions create a high wildfire risk. Avoid open flames and outdoor burning.',
'fire weather watch': 'Conditions may soon create a high wildfire risk.',
'rip current statement': 'Dangerous rip currents at the beach. Stay out of the water or swim near a lifeguard.',
'coastal flood advisory': 'Minor coastal flooding is expected.',
'coastal flood warning': 'Significant coastal flooding is occurring or imminent.',
'small craft advisory': 'Winds or seas hazardous to small boats.',
'tropical storm watch': 'Tropical storm conditions are possible within about 48 hours.',
'tropical storm warning': 'Tropical storm conditions are expected within about 36 hours.',
'hurricane watch': 'Hurricane conditions are possible within about 48 hours.',
'hurricane warning': 'Hurricane conditions are expected within about 36 hours.',
'hazardous weather outlook': 'An outlook of possible hazardous weather in the next several days.',
'severe weather statement': 'An update on an ongoing severe storm warning.',
'flood statement': 'An update on an ongoing flood warning.',
'storm surge watch': 'Life-threatening storm surge flooding is possible within about 48 hours. Know your evacuation zone and be ready to leave.',
'storm surge warning': 'Life-threatening storm surge flooding is expected within about 36 hours. Follow evacuation orders from local officials immediately.',
'hurricane local statement': 'Local details on a hurricane threat: expected winds, rain, surge and tornado risk for your area, and what officials are advising.',
'tropical cyclone statement': 'Local details on a tropical storm or hurricane threat for your area.',
'tropical depression watch': 'A tropical depression may affect the area; heavy rain and gusty wind are possible.',
'extreme wind warning': 'Extreme, hurricane-force winds (115+ mph) are about to hit. Take shelter now in the strongest part of a sturdy building.',
'hurricane force wind watch': 'Hurricane-force winds are possible over marine areas. Stay in port.',
'hurricane force wind warning': 'Hurricane-force winds are expected over marine areas. Dangerous for all vessels.',
'storm warning': 'Strong marine winds (48–63 knots) expected. Dangerous for all vessels.',
'gale warning': 'Strong marine winds (34–47 knots) expected. Hazardous for small and large vessels.',
'gale watch': 'Gale-force marine winds are possible.',
'special marine warning': 'Short-lived, hazardous marine weather (strong winds, waterspouts, hail) is happening. Seek safe harbor.',
'high surf advisory': 'Large breaking waves make swimming and surfing dangerous.',
'high surf warning': 'Very large breaking waves are expected: dangerous and life-threatening at the shore.',
'beach hazards statement': 'Hazards at the beach such as rip currents or high surf. Swim near a lifeguard.',
'lake wind advisory': 'Gusty lake winds can create hazardous waves for small boats.',
'dust storm warning': 'Blowing dust is reducing visibility to near zero. Pull off the road and turn off your lights.',
'tornado emergency': 'A confirmed large and destructive tornado is moving toward a populated area. Take cover immediately.',
'flash flood emergency': 'Catastrophic, life-threatening flash flooding is happening. Move to higher ground now.',
};
function meaning(event) {
const e = String(event || '').toLowerCase().trim();
if (GLOSSARY[e]) return GLOSSARY[e];
if (e.endsWith('watch')) return 'A Watch: conditions are favorable for this hazard. Stay alert and be ready to act.';
if (e.endsWith('warning')) return 'A Warning: this hazard is happening or imminent. Take action now.';
if (e.endsWith('advisory')) return 'An Advisory: conditions may cause inconvenience or danger if you are not careful.';
if (e.endsWith('statement')) return 'An informational statement about developing or ongoing weather.';
return '';
}
// NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part
function firstSentence(t, max = 220) {
const what = String(t || '').match(/\*\s*WHAT\.\.\.([\s\S]*?)(?:\n\s*\n|\*\s*[A-Z ]+\.\.\.|$)/);
const c = (what ? what[1] : String(t || '')).replace(/\s+/g, ' ').trim();
const m = c.match(/^.*?[.!?](\s|$)/);
const r = (m ? m[0] : c).trim();
return r.length > max ? r.slice(0, max - 3) + '…' : r;
}
const area = (a) => String(a || '').replace(/;\s*/g, ', ');
const when = (iso) => (iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// "ends in 3h 10m" / "ended" for active alerts
function remaining(iso) {
if (!iso) return '';
const ms = Date.parse(iso) - Date.now();
if (!Number.isFinite(ms)) return '';
if (ms <= 0) return 'expired';
const m = Math.round(ms / 60000);
return m < 60 ? `ends in ${m} min` : m < 1440 ? `ends in ${Math.floor(m / 60)}h ${m % 60}m` : `ends in ${Math.round(m / 144) / 10}d`.replace('.0d', 'd');
}
window.AlertInfo = { meaning, firstSentence, area, when, esc, remaining, GLOSSARY };
})();
+350
View File
@@ -0,0 +1,350 @@
/* 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: calc(58px + env(safe-area-inset-top, 0px));
--gutter: 1rem;
}
html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
/* on <html> (the viewport): putting this on <body> can block wheel scrolling when body is a scroll container */
html { overscroll-behavior-y: none; }
button, a, summary { touch-action: manipulation; }
/* ── Layout ── */
.app { max-width: 980px; padding: 0 var(--gutter) 4rem; }
@media (min-width: 900px) { :root { --gutter: 1.5rem; } }
/* ── Slim top bar ── */
.topbar {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; gap: 0.75rem;
margin: 0 calc(-1 * var(--gutter)) 1rem; padding: calc(0.55rem + env(safe-area-inset-top, 0px)) calc(var(--gutter) + env(safe-area-inset-right, 0px)) 0.55rem calc(var(--gutter) + 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-bottom: 1.25rem; } }
.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) {
:root { --gutter: 0.9rem; }
.app { padding: 0 calc(var(--gutter) + env(safe-area-inset-right, 0px)) max(5.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))) calc(var(--gutter) + env(safe-area-inset-left, 0px)); }
.topbar { margin: 0 calc(-1 * (var(--gutter) + env(safe-area-inset-right, 0px))) 0.8rem calc(-1 * (var(--gutter) + env(safe-area-inset-left, 0px))); }
.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) - 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; } }
/* ── Install guide ── */
.install-banner { display: flex; align-items: center; gap: 0.7rem; margin: 0 0 0.9rem; padding: 0.65rem 0.8rem; border-radius: 16px;
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, var(--card-bg)), var(--card-bg)); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); }
.install-banner.hidden { display: none; }
.ib-ico { font-size: 1.6rem; }
.ib-text { flex: 1; min-width: 0; display: flex; flex-direction: column; font-size: 0.85rem; line-height: 1.25; }
.ib-text span { font-size: 0.74rem; color: var(--ink-light); }
.ib-btn { font: inherit; font-weight: 700; font-size: 0.82rem; min-height: 40px; padding: 0 1rem; border-radius: 999px; border: none; background: var(--accent); color: #fff; cursor: pointer; }
.ib-x { font: inherit; width: 36px; height: 36px; border: none; background: transparent; color: var(--ink-light); font-size: 1rem; cursor: pointer; border-radius: 50%; }
.install-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px; padding: 1rem 1.1rem; margin-bottom: 1.4rem; }
.ins-lead { font-size: 0.88rem; margin-bottom: 0.8rem; color: var(--ink-mid); }
.ins-done { font-weight: 700; color: #22c55e; margin-bottom: 0.4rem; }
.ins-tabs { display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px; background: var(--paper-darker); margin-bottom: 0.8rem; }
.ins-tab { font: inherit; font-size: 0.8rem; font-weight: 700; min-height: 36px; padding: 0 0.9rem; border: none; border-radius: 999px; background: transparent; color: var(--ink-light); cursor: pointer; }
.ins-tab.on { background: var(--accent); color: #fff; }
.ins-steps { margin: 0 0 0.6rem 1.2rem; display: grid; gap: 0.55rem; font-size: 0.9rem; line-height: 1.4; }
.ins-steps li::marker { font-weight: 800; color: var(--accent); }
.ins-ico { vertical-align: -5px; margin: 0 2px; color: var(--accent); }
.ins-note { font-size: 0.78rem; color: var(--ink-light); line-height: 1.5; margin-top: 0.5rem; }
.ins-install { font: inherit; font-weight: 700; min-height: 44px; padding: 0 1.4rem; margin-bottom: 0.8rem; border: none; border-radius: 999px; background: var(--accent); color: #fff; cursor: pointer; }
.install-dialog { border: 1px solid var(--border); border-radius: 20px; background: var(--paper-dark); color: var(--ink); padding: 1.1rem 1.2rem 1.3rem; width: min(440px, 92vw); margin: auto; }
.install-dialog::backdrop { background: rgba(0,0,0,.6); }
.install-dialog-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.7rem; }
.install-dialog-head h2 { font-size: 1.1rem; }
.install-dialog-head button { font: inherit; width: 36px; height: 36px; border: none; background: transparent; color: var(--ink-light); font-size: 1rem; cursor: pointer; border-radius: 50%; }
/* Landscape phones: side insets for the content column (the top bar already adds its own) */
.app { padding-left: calc(var(--gutter) + env(safe-area-inset-left, 0px)); padding-right: calc(var(--gutter) + env(safe-area-inset-right, 0px)); }
.topbar { margin-left: calc(-1 * (var(--gutter) + env(safe-area-inset-left, 0px))); margin-right: calc(-1 * (var(--gutter) + env(safe-area-inset-right, 0px))); }
.install-dialog { margin-top: max(1rem, env(safe-area-inset-top, 0px)); }
/* ── Map tab: full-bleed map with floating glass controls (Apple-Weather style) ── */
:root { --nav-h: 0px; --hud-top: 12px; --map-safe-b: 0px; --hud-side: 12px; }
@media (max-width: 640px) { :root { --nav-h: calc(58px + max(6px, env(safe-area-inset-bottom, 0px))); } }
body.map-fs { --hud-top: calc(12px + env(safe-area-inset-top, 0px)); --map-safe-b: env(safe-area-inset-bottom, 0px); }
.glass { background: color-mix(in srgb, var(--paper-dark) 74%, transparent); backdrop-filter: blur(18px) saturate(1.5); -webkit-backdrop-filter: blur(18px) saturate(1.5);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.3); }
/* The tab fills everything between the top bar and the bottom nav; no page scrolling here */
body:not(.map-fs) #tab-unified-map:not(.hidden) { position: fixed; top: var(--topbar-h); left: 0; right: 0; bottom: var(--nav-h); z-index: 60; animation: none; }
body[data-tab="unified-map"] { overflow: hidden; }
html:has(body[data-tab="unified-map"]) { overflow: hidden; }
#tab-unified-map .unified-map-wrap { position: absolute; inset: 0; padding: 0; border: 0; border-radius: 0; background: transparent; display: block; height: auto !important; min-height: 0; }
#tab-unified-map .unified-map-shell { position: absolute; inset: 0; }
#tab-unified-map .unified-map-container { position: absolute; inset: 0; height: 100% !important; min-height: 0; border: 0; border-radius: 0; }
body.map-fs #tab-unified-map { padding: 0; }
/* Floating controls, top right */
.map-hud { position: absolute; top: var(--hud-top); right: max(var(--hud-side), env(safe-area-inset-right, 0px)); display: flex; flex-direction: column; gap: 10px; z-index: 700; }
.hud-btn { width: 46px; height: 46px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; cursor: pointer; color: var(--ink); font: inherit; font-size: 1.15rem;
background: color-mix(in srgb, var(--paper-dark) 78%, transparent); backdrop-filter: blur(18px) saturate(1.5); -webkit-backdrop-filter: blur(18px) saturate(1.5);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); transition: transform .12s, background .15s, color .15s; }
.hud-btn:active { transform: scale(0.94); }
.hud-btn:hover { color: var(--accent); }
.hud-btn.on { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Readout chips, top left */
#tab-unified-map .map-overlay-readouts { position: absolute; top: var(--hud-top); left: max(var(--hud-side), env(safe-area-inset-left, 0px)); right: auto; bottom: auto; flex-direction: column; align-items: flex-start; gap: 8px; z-index: 700; }
#tab-unified-map .map-readout-chip { background: color-mix(in srgb, var(--paper-dark) 78%, transparent); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); color: var(--ink); font-weight: 700; padding: 0.4rem 0.8rem; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
/* Layers sheet: floats beside the controls */
#tab-unified-map .map-layer-menu-panel { position: absolute; top: var(--hud-top); right: calc(max(var(--hud-side), env(safe-area-inset-right, 0px)) + 58px); z-index: 710; width: min(330px, calc(100% - 96px)); max-height: calc(100% - var(--hud-top) - 185px);
overflow: auto; margin: 0; padding: 0.8rem 0.9rem 0.9rem; border-radius: 20px; }
.lm-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.6rem; font-size: 1rem; }
.lm-close { width: 34px; height: 34px; border: none; border-radius: 50%; background: var(--paper-darker); color: var(--ink-light); font: inherit; cursor: pointer; }
.lm-style { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.8rem; }
.lm-style select { flex: 1; max-width: 190px; min-height: 40px; font-size: 16px; }
#tab-unified-map .map-layer-menu-title { font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-light); margin-bottom: 0.4rem; }
#tab-unified-map .map-controls-toggle-grid { display: grid; grid-template-columns: 1fr; gap: 2px; }
#tab-unified-map .toggle-control { padding: 0.45rem 0.2rem; min-height: 44px; }
/* Bottom: weather summary + radar timeline float over the map */
.map-bottom { position: absolute; left: max(var(--hud-side), env(safe-area-inset-left, 0px)); right: max(var(--hud-side), env(safe-area-inset-right, 0px)); bottom: calc(12px + var(--map-safe-b)); z-index: 650; display: flex; flex-direction: column; gap: 10px; pointer-events: none; }
.map-bottom > * { pointer-events: auto; }
#tab-unified-map .map-timeline-wrap { padding: 0.55rem 0.75rem 0.6rem; border-radius: 18px; border-top: 1px solid color-mix(in srgb, var(--border) 65%, transparent); }
#tab-unified-map .map-timeline-wrap.hidden, #tab-unified-map .map-data-panel.hidden { display: none; }
#tab-unified-map .map-data-panel { align-self: flex-start; max-width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 1.1rem; padding: 0.5rem 0.9rem; border-radius: 16px; }
#tab-unified-map .panel-location { font-weight: 800; font-size: 0.9rem; }
#tab-unified-map .panel-data { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; }
#tab-unified-map .data-item { display: flex; flex-direction: row; align-items: baseline; gap: 0.35rem; font-size: 0.82rem; }
@media (max-width: 640px) { #tab-unified-map .data-item:nth-child(2), #tab-unified-map .data-item:nth-child(4), #tab-unified-map .data-item:nth-child(5) { display: none; } }
/* MapLibre zoom buttons: tucked under the control stack on desktop, hidden on touch */
#unified-map .maplibregl-ctrl-top-right { margin-top: calc(var(--hud-top) + 166px); margin-right: max(var(--hud-side), env(safe-area-inset-right, 0px)); }
#unified-map .maplibregl-ctrl-top-right .maplibregl-ctrl { margin: 0; }
@media (pointer: coarse) { #unified-map .maplibregl-ctrl-top-right { display: none; } }
/* ── Alerts tab: hurricane cards + rich alert cards ── */
.hurricane-storm-card { border-radius: 16px; padding: 0.9rem 1rem; }
.hurricane-name { font-weight: 800; font-size: 1.05rem; }
.hurricane-stats b { color: var(--ink); font-size: 0.95rem; }
.hurricane-stats small { opacity: .75; }
.hurricane-meta { display: flex; flex-wrap: wrap; gap: 0.3rem 0.9rem; align-items: center; margin-top: 0.5rem; font-size: 0.74rem; color: var(--ink-light); }
.hurricane-link { color: var(--accent); font-weight: 600; text-decoration: none; }
.hurricane-link:hover { text-decoration: underline; }
.hurricane-map-hint { margin-left: auto; color: var(--accent); font-weight: 600; }
.alert-rich { border-radius: 16px; padding: 0.95rem 1.05rem; }
.alert-why { font-size: 0.95rem; font-weight: 600; line-height: 1.4; margin: 0.35rem 0 0.5rem; }
.alert-facts { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.55rem; }
.alert-left { font-weight: 700; color: var(--accent); }
.alert-meaning { font-size: 0.84rem; line-height: 1.45; padding: 0.55rem 0.75rem; border-radius: 12px; background: color-mix(in srgb, var(--accent) 9%, transparent); margin-bottom: 0.5rem; }
.alert-rich .alert-action { font-size: 0.86rem; line-height: 1.45; }
.alert-more { margin-top: 0.6rem; border-top: 1px dashed var(--border); padding-top: 0.5rem; }
.alert-more summary { cursor: pointer; font-size: 0.8rem; font-weight: 700; color: var(--accent); min-height: 36px; display: flex; align-items: center; list-style: none; }
.alert-more summary::-webkit-details-marker { display: none; }
.alert-more summary::before { content: '▸'; margin-right: 0.4rem; transition: transform .15s; }
.alert-more[open] summary::before { transform: rotate(90deg); }
.alert-full { font-size: 0.84rem; line-height: 1.5; }
.alert-full p { margin: 0 0 0.6rem; }
.alert-pre { white-space: pre-wrap; }
.alert-meta { font-size: 0.74rem; color: var(--ink-light); }
/* Location permission status line */
.geo-status { font-size: 0.8rem; line-height: 1.45; color: var(--ink-light); margin: 0.5rem 0.15rem 0; padding: 0.5rem 0.75rem; border-radius: 12px; background: var(--paper-darker); }
.geo-status.hidden { display: none; }
.geo-status.ok { color: #22c55e; }
.geo-status.warn { color: var(--ink); background: color-mix(in srgb, #ef4444 14%, var(--paper-darker)); border: 1px solid color-mix(in srgb, #ef4444 45%, transparent); }
/* ── Performance on phones ──
backdrop-filter re-blurs everything behind it on every frame. Over a live WebGL map, an animated sky
or sticky headings that is what makes iPhones stutter and lock up, so those use solid translucent fills. */
.hero-hilo, .sticky-head, .glass, .hud-btn, #tab-unified-map .map-readout-chip,
.map-layer-menu-panel, .map-timeline-wrap, .map-data-panel, .install-banner {
-webkit-backdrop-filter: none !important; backdrop-filter: none !important;
}
.glass, .hud-btn, #tab-unified-map .map-readout-chip { background: color-mix(in srgb, var(--paper-dark) 92%, transparent) !important; }
.sticky-head { background: color-mix(in srgb, var(--paper) 96%, transparent) !important; }
.hero-hilo { background: rgba(0, 0, 0, 0.24) !important; }
.topbar, .bottom-nav { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
/* readout chips follow their layer toggle (author display rules would otherwise beat [hidden]) */
#tab-unified-map .map-overlay-readouts[hidden], #tab-unified-map .map-readout-chip[hidden] { display: none !important; }
/* Location dot: the one thing on the map by default, so make it unmistakable (static halo, no animation) */
.map-location-dot { width: 20px; height: 20px; background: #2563eb; border: 3px solid #fff; border-radius: 50%; box-shadow: 0 0 0 7px rgba(37, 99, 235, 0.28), 0 2px 8px rgba(0, 0, 0, 0.55); }
+570
View File
@@ -0,0 +1,570 @@
/* 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);
};
}
// Keep sticky section headings flush under the (safe-area aware) top bar
const topbarEl = document.querySelector('.topbar');
if (topbarEl && 'ResizeObserver' in window) {
new ResizeObserver(() => document.documentElement.style.setProperty('--topbar-h', topbarEl.offsetHeight + 'px')).observe(topbarEl);
}
// ── 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, last: 0, onScreen: true };
const PHONE = window.matchMedia('(max-width: 640px)').matches;
const fxShouldRun = () => fx.parts.length && !REDUCED && !document.hidden && fx.onScreen && document.body.dataset.tab === 'conditions';
function fxKick() { if (!fx.raf && fxShouldRun()) fx.raf = requestAnimationFrame(fxFrame); }
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 base = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0;
const n = Math.round(base * (PHONE ? 0.55 : 1));
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) {
fx.raf = 0;
const canvas = $('sky-fx');
if (!canvas || !fxShouldRun()) return; // hidden tab / scrolled away / app in background: stop drawing entirely
if (t - fx.last < 33) { fx.raf = requestAnimationFrame(fxFrame); return; } // cap at ~30 fps
fx.last = t;
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();
}
}
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.raf = 0;
fx.kind = kind;
fxResize(canvas);
fxSeed(kind);
if (fx.parts.length) fxKick();
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', fxKick);
if ('IntersectionObserver' in window && $('hero')) new IntersectionObserver((es) => { fx.onScreen = es[0].isIntersecting; fxKick(); }).observe($('hero'));
// ── 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);
}
// ── Map tab: independent, remembered view (not tied to the main-page radar) ──
let mapCenteredFor = null;
const mapCityKey = (c) => `${(+c.latitude).toFixed(2)},${(+c.longitude).toFixed(2)}`;
function centerMap(city, force) {
const m = typeof mapsOverview !== 'undefined' ? mapsOverview?.map : null;
if (!m || city?.latitude == null) return;
const key = mapCityKey(city);
if (!force && mapCenteredFor === key) return; // keep wherever the user panned/zoomed to
let saved = null;
try { saved = JSON.parse(localStorage.getItem('mapView') || 'null'); } catch {}
if (!force && !mapCenteredFor && saved && saved.key === key && Number.isFinite(saved.zoom)) m.jumpTo({ center: [saved.lng, saved.lat], zoom: saved.zoom });
else mapsOverview.panTo(city.latitude, city.longitude, force ? 7 : 5); // wider regional view than the main-page radar
mapCenteredFor = key;
}
(() => {
const m = typeof mapsOverview !== 'undefined' ? mapsOverview?.map : null;
if (!m) return;
let t = 0;
m.on('moveend', () => {
if (!mapCenteredFor) return;
clearTimeout(t);
t = setTimeout(() => { try { const c = m.getCenter(); localStorage.setItem('mapView', JSON.stringify({ key: mapCenteredFor, lng: c.lng, lat: c.lat, zoom: m.getZoom() })); } catch {} }, 400);
});
$('map-recenter-btn')?.addEventListener('click', () => { if (state.currentCity) centerMap(state.currentCity, true); });
})();
// ── Location permission status (so "blocked" is visible, not guessed) ────────
const GeoStatus = (() => {
const el = $('geo-status');
let status = null;
async function render() {
if (!el || !navigator.permissions || !navigator.geolocation) return;
try {
if (!status) {
status = await navigator.permissions.query({ name: 'geolocation' });
status.addEventListener('change', render);
}
} catch { return; }
const s = status.state;
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
if (s === 'granted') { el.className = 'geo-status ok'; el.textContent = '📍 Location: allowed. Tap ⌖ to use it.'; }
else if (s === 'prompt') { el.className = 'geo-status'; el.textContent = '📍 Location: not asked yet. Tap ⌖ and choose Allow when your phone asks.'; }
else if (s === 'denied') {
el.className = 'geo-status warn';
el.textContent = ios
? `📍 Location: your phone has this ${standalone ? 'app' : 'site'} set to Don't Allow. Fix it in Settings → Privacy & Security → Location Services → ${standalone ? '"NeCloud Weather"' : 'Safari Websites'}. A Safari content blocker can also do this: try aA → Turn Off Content Blockers.`
: '📍 Location: blocked for this site. Click the lock icon in the address bar → Location → Allow.';
} else { el.classList.add('hidden'); return; }
el.classList.remove('hidden');
}
render();
return { refresh: render };
})();
// ── 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();
},
centerMap,
refreshGeoStatus: () => GeoStatus.refresh(),
onAir(air) { renderAqi(air); },
refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fx.raf = 0; fxResize(c); fxSeed(fx.kind); fxKick(); } },
onStation() { syncTopbarLocation(); renderStationContext(); },
};
// ── Install as an app (PWA): guidance for iPhone/iPad and Android ────────────
const Install = (() => {
const ua = navigator.userAgent || '';
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isAndroid = /Android/i.test(ua);
const standalone = () => window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
let deferred = null; // Chrome/Edge/Samsung install prompt
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; refresh(); });
window.addEventListener('appinstalled', () => { deferred = null; localStorage.setItem('installDone', '1'); refresh(); });
const SHARE = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 3v12M12 3 8 7M12 3l4 4M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const DOTS = '<svg class="ins-ico" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="5" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="19" r="2" fill="currentColor"/></svg>';
const steps = {
ios: `<ol class="ins-steps">
<li>Open this page in <b>Safari</b>. (On iOS 16.4+ Chrome and Edge can do it too.)</li>
<li>Tap the <b>Share</b> button ${SHARE} at the bottom of the screen (top right on iPad).</li>
<li>Scroll down and tap <b>Add to Home Screen</b>.</li>
<li>Tap <b>Add</b>. The app icon appears on your Home Screen.</li>
</ol>
<p class="ins-note">Open it from the Home Screen icon for the full-screen app. iPhone has no automatic install pop-up, so this manual step is the only way.</p>`,
android: `<ol class="ins-steps">
<li>Open this page in <b>Chrome</b>.</li>
<li>Tap the <b>menu</b> ${DOTS} (top right).</li>
<li>Tap <b>Install app</b> (or <b>Add to Home screen</b>).</li>
<li>Tap <b>Install</b>. The app shows up in your app drawer and on your Home screen.</li>
</ol>
<p class="ins-note"><b>Samsung Internet:</b> menu (☰) → <b>Add page to</b> → <b>Home screen</b>. <b>Firefox:</b> menu → <b>Install</b>.</p>`,
};
const other = '<p class="ins-note">Desktop Chrome/Edge: click the install icon in the address bar (or menu → <b>Install NeCloud Weather</b>).</p>';
const dashNote = '<p class="ins-note">The <b>Station Dashboard</b> installs separately: open <b>/dashboard</b> and follow the same steps to get its own icon.</p>';
function platform() { return isIOS ? 'ios' : isAndroid ? 'android' : null; }
function body(active) {
const p = active || platform() || 'ios';
const tabs = `<div class="ins-tabs" role="tablist">
<button type="button" role="tab" class="ins-tab${p === 'ios' ? ' on' : ''}" data-p="ios">iPhone / iPad</button>
<button type="button" role="tab" class="ins-tab${p === 'android' ? ' on' : ''}" data-p="android">Android</button></div>`;
const promptBtn = deferred && p === 'android' ? '<button type="button" class="ins-install" id="ins-native">Install now</button>' : '';
return `${tabs}<div class="ins-body" data-p="${p}">${promptBtn}${steps[p]}${!platform() ? other : ''}${dashNote}</div>`;
}
function wire(root) {
root.querySelectorAll('.ins-tab').forEach((b) => b.addEventListener('click', () => { root.innerHTML = body(b.dataset.p); wire(root); }));
root.querySelector('#ins-native')?.addEventListener('click', async () => {
if (!deferred) return;
deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh();
});
}
function renderCard() {
const card = $('install-card');
if (!card) return;
if (standalone()) {
card.innerHTML = '<p class="ins-done">✓ You\'re using the installed app.</p>' + dashNote;
return;
}
card.innerHTML = `<p class="ins-lead">Add NeCloud Weather to your phone like a normal app: full screen, its own icon, and it opens instantly.</p><div class="ins-wrap">${body()}</div>`;
wire(card.querySelector('.ins-wrap'));
}
// Banner: phones only, not installed, not dismissed within 30 days
function renderBanner() {
const el = $('install-banner');
if (!el) return;
let dismissed = 0; try { dismissed = +localStorage.getItem('installDismissed') || 0; } catch {}
const show = !standalone() && (isIOS || isAndroid) && Date.now() - dismissed > 30 * 86400000 && !localStorage.getItem('installDone');
el.classList.toggle('hidden', !show);
$('ib-how').textContent = deferred ? 'Install' : 'How';
}
function openDialog() {
const dlg = $('install-dialog'), b = $('install-dialog-body');
if (!dlg || !b) return;
b.innerHTML = body(); wire(b);
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
}
function refresh() { renderBanner(); renderCard(); }
$('ib-how')?.addEventListener('click', async () => {
if (deferred) { deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh(); }
else openDialog();
});
$('ib-close')?.addEventListener('click', () => { try { localStorage.setItem('installDismissed', String(Date.now())); } catch {} $('install-banner').classList.add('hidden'); });
$('install-dialog-close')?.addEventListener('click', () => $('install-dialog').close());
$('install-dialog')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); });
refresh();
return { refresh, openDialog };
})();
syncTopbarLocation();
new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true });
})();
+361 -258
View File
@@ -62,55 +62,104 @@ function parseNhcCoord(str) {
let hurricaneStorms = [];
// ── NHC storm model ──────────────────────────────────────────────────────────
// NHC's CurrentStorms.json reports `intensity` in KNOTS and `movementSpeed` in mph.
const KT_TO_MPH = 1.15078;
const STORM_CLASS = {
TD: 'Tropical Depression', TS: 'Tropical Storm', HU: 'Hurricane', TY: 'Typhoon',
STD: 'Subtropical Depression', STS: 'Subtropical Storm', SD: 'Subtropical Depression', SS: 'Subtropical Storm',
PTC: 'Potential Tropical Cyclone', PC: 'Post-Tropical Cyclone', EX: 'Extratropical Cyclone',
};
const SAFFIR_COLORS = { td: '#5dade2', ts: '#27ae60', 1: '#f1c40f', 2: '#f39c12', 3: '#e74c3c', 4: '#c0392b', 5: '#9b59b6', ex: '#95a5a6' };
const compass16 = deg => ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(deg / 22.5) % 16];
function saffirCategory(kt) { return kt >= 137 ? 5 : kt >= 113 ? 4 : kt >= 96 ? 3 : kt >= 83 ? 2 : kt >= 64 ? 1 : 0; }
// Colour for a point on the forecast track, from its own forecast wind (knots)
function windColor(kt) { return kt >= 64 ? SAFFIR_COLORS[Math.max(1, saffirCategory(kt))] : kt >= 34 ? SAFFIR_COLORS.ts : SAFFIR_COLORS.td; }
function escHtml(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function fmtLatLon(lat, lon) {
const la = isNaN(lat) ? '--' : `${Math.abs(lat).toFixed(1)}°${lat < 0 ? 'S' : 'N'}`;
const lo = isNaN(lon) ? '--' : `${Math.abs(lon).toFixed(1)}°${lon < 0 ? 'W' : 'E'}`;
return `${la}, ${lo}`;
}
function milesBetween(lat1, lon1, lat2, lon2) {
const R = 3958.8, r = Math.PI / 180, dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(x));
}
function bearingDeg(lat1, lon1, lat2, lon2) {
const r = Math.PI / 180, y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r);
const x = Math.cos(lat1 * r) * Math.sin(lat2 * r) - Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r);
return (Math.atan2(y, x) / r + 360) % 360;
}
function stormInfo(s) {
const cls = String(s.classification || '').toUpperCase();
const kt = parseFloat(s.intensity);
const hasKt = Number.isFinite(kt);
const mph = hasKt ? Math.round(kt * KT_TO_MPH) : null;
const isHurr = cls === 'HU' || cls === 'TY';
const cat = isHurr && hasKt ? Math.max(1, saffirCategory(kt)) : isHurr ? 1 : 0;
const base = STORM_CLASS[cls] || 'Tropical Cyclone';
const label = cat ? `Category ${cat} ${cls === 'TY' ? 'Typhoon' : 'Hurricane'}${cat >= 3 ? ' (major)' : ''}` : base;
const badge = cat ? `cat-${cat}` : (cls === 'TS' || cls === 'STS' || cls === 'SS') ? 'cat-ts' : (cls === 'TD' || cls === 'STD' || cls === 'SD' || cls === 'PTC') ? 'cat-td' : 'cat-ex';
const color = cat ? SAFFIR_COLORS[cat] : badge === 'cat-ts' ? SAFFIR_COLORS.ts : badge === 'cat-td' ? SAFFIR_COLORS.td : SAFFIR_COLORS.ex;
const lat = Number.isFinite(s.latitudeNumeric) ? s.latitudeNumeric : parseNhcCoord(s.latitude);
const lon = Number.isFinite(s.longitudeNumeric) ? s.longitudeNumeric : parseNhcCoord(s.longitude);
const title = s.name ? `${base} ${s.name}` : base;
let movement = '';
if (Number.isFinite(s.movementSpeed)) {
movement = s.movementSpeed < 1 ? 'Nearly stationary'
: `Moving ${Number.isFinite(s.movementDir) ? compass16(s.movementDir) + ' ' : ''}at ${Math.round(s.movementSpeed)} mph`;
}
return { cls, kt: hasKt ? kt : null, mph, cat, label, base, title, badge, color, lat, lon, movement };
}
function renderHurricanePanel(storms) {
const panel = document.getElementById('hurricane-panel');
if (!panel) return;
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
hurricaneStorms = storms;
const catLabel = c => {
if (!c) return '';
const up = c.toUpperCase();
if (up === 'HU') return 'cat-1';
if (up === 'TS') return 'cat-ts';
if (up === 'TD') return 'cat-td';
const m = c.match(/(\d)/);
if (m) return `cat-${m[1]}`;
return 'cat-td';
};
const catName = c => {
if (!c) return 'Tropical';
const up = c.toUpperCase();
if (up === 'HU') return 'Hurricane';
if (up === 'TS') return 'Tropical Storm';
if (up === 'TD') return 'Tropical Depression';
return c;
};
const here = state.currentCity;
panel.innerHTML = storms.map(s => {
const lat = parseNhcCoord(s.latitude);
const lon = parseNhcCoord(s.longitude);
const i = stormInfo(s);
let away = '';
if (here && !isNaN(i.lat) && !isNaN(i.lon)) {
const mi = milesBetween(here.latitude, here.longitude, i.lat, i.lon);
const dir = compass16(bearingDeg(here.latitude, here.longitude, i.lat, i.lon));
away = `<span>≈ ${Math.round(state.units === 'us' ? mi : mi * 1.60934).toLocaleString()} ${state.units === 'us' ? 'mi' : 'km'} ${dir} of ${escHtml(here.name || 'you')}</span>`;
}
const updated = s.lastUpdate ? new Date(s.lastUpdate).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) : '';
const adv = s.publicAdvisory;
return `
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map">
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map" data-storm="${escHtml(s.id || '')}">
<div class="hurricane-header">
<span class="hurricane-name">${s.name || 'Storm'}</span>
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span>
<span class="hurricane-name">${escHtml(i.title)}</span>
<span class="hurricane-cat-badge ${i.badge}" style="background:${i.color}">${escHtml(i.cat ? 'Category ' + i.cat : i.base)}</span>
</div>
<div class="hurricane-stats">
<span>Winds: ${s.intensity || '--'} mph</span>
${s.pressure ? `<span>Pressure: ${s.pressure} mb</span>` : ''}
<span>${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')}</span>
${s.movement ? `<span>Moving ${s.movement}</span>` : ''}
<span style="color:var(--accent,#4da6ff);font-size:0.75em">🗺 View on map</span>
<span><b>${i.mph != null ? i.mph + ' mph' : '--'}</b> winds${i.kt != null ? ` <small>(${Math.round(i.kt)} kt)</small>` : ''}</span>
${s.pressure ? `<span>${escHtml(s.pressure)} mb</span>` : ''}
<span>${fmtLatLon(i.lat, i.lon)}</span>
${i.movement ? `<span>${escHtml(i.movement)}</span>` : ''}
${away}
</div>
</div>
`}).join('');
panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => {
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i]));
<div class="hurricane-meta">
${updated ? `<span>Updated ${escHtml(updated)}</span>` : ''}
${adv?.advNum ? `<span>Advisory ${escHtml(adv.advNum)}</span>` : ''}
${adv?.url ? `<a href="${escHtml(adv.url)}" target="_blank" rel="noopener" class="hurricane-link">Official advisory ↗</a>` : ''}
<span class="hurricane-map-hint">🗺 View on map</span>
</div>
</div>`;
}).join('');
panel.querySelectorAll('.hurricane-storm-card').forEach((card, idx) => {
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[idx]));
card.querySelector('.hurricane-link')?.addEventListener('click', e => e.stopPropagation());
});
}
async function flyToHurricane(storm) {
const lat = parseNhcCoord(storm.latitude);
const lon = parseNhcCoord(storm.longitude);
const info = stormInfo(storm);
const { lat, lon } = info;
if (isNaN(lat) || isNaN(lon)) return;
activateTab('unified-map');
@@ -121,20 +170,30 @@ async function flyToHurricane(storm) {
if (cb) cb.checked = true;
}
// Ground radar doesn't reach open ocean, so show infrared satellite (the storm's actual cloud
// structure). Goes through the layer checkbox so state and storage stay in sync.
const sat = document.querySelector('#tab-unified-map input[data-layer="satellite"]');
if (sat && !sat.checked) {
sat.checked = true;
sat.dispatchEvent(new Event('change'));
// a storm tap is a one-off look, not a change to the user's saved map layers
try { localStorage.removeItem(STORAGE_KEYS.showSatelliteLayer); } catch {}
}
await new Promise(r => setTimeout(r, 450));
const map = mapsOverview.map;
if (!map) return;
if (!map.loaded()) await new Promise(resolve => map.once('load', resolve));
// 'load' fires only once per map, so waiting on it hangs forever whenever tiles are mid-load;
// flyTo works regardless, so just make sure the canvas has its real size first.
map.resize();
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 });
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900, padding: { bottom: 200 } });
const cat = (storm.classification || '').toUpperCase();
const label = cat === 'HU' ? 'Hurricane' : cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
setTimeout(() => {
new maplibregl.Popup({ closeButton: true, maxWidth: '240px' })
new maplibregl.Popup({ closeButton: true, maxWidth: '260px' })
.setLngLat([lon, lat])
.setHTML(`<b>${storm.name || 'Storm'}</b><br>${label}<br>Winds: ${storm.intensity || '--'} mph${storm.movement ? '<br>Moving ' + storm.movement : ''}`)
.setHTML(`<b>${escHtml(info.title)}</b><br>${escHtml(info.label)}<br>Winds: ${info.mph != null ? info.mph + ' mph' : '--'}${info.movement ? '<br>' + escHtml(info.movement) : ''}`)
.addTo(map);
}, 950);
@@ -158,20 +217,22 @@ async function flyToHurricane(storm) {
if (lin?.type === 'FeatureCollection' && lin.features?.length) {
map.addSource('hurricane-track-lin', { type: 'geojson', data: lin });
map.addLayer({ id: 'hurricane-track-line', type: 'line', source: 'hurricane-track-lin',
paint: { 'line-color': '#ff5722', 'line-width': 2.5, 'line-dasharray': [4, 2] } });
paint: { 'line-color': '#ff7043', 'line-width': 2.5, 'line-dasharray': [4, 2] } });
}
if (pts?.type === 'FeatureCollection' && pts.features?.length) {
map.addSource('hurricane-track-pts-src', { type: 'geojson', data: pts });
map.addLayer({ id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src',
// Each forecast point is coloured by its own forecast strength (not the storm's current class)
map.addLayer({
id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src',
paint: {
'circle-radius': 7,
'circle-color': ['match', ['get', 'TCDVLP'],
'Hurricane', '#f44336', 'HU', '#f44336',
'Tropical Storm', '#9c27b0', 'TS', '#9c27b0',
'#2196f3'],
'circle-color': ['step', ['to-number', ['get', 'MAXWIND'], 0],
SAFFIR_COLORS.td, 34, SAFFIR_COLORS.ts, 64, SAFFIR_COLORS[1], 83, SAFFIR_COLORS[2],
96, SAFFIR_COLORS[3], 113, SAFFIR_COLORS[4], 137, SAFFIR_COLORS[5]],
'circle-stroke-width': 2, 'circle-stroke-color': '#fff',
} });
},
});
const allCoords = pts.features.map(f => f.geometry.coordinates);
if (allCoords.length > 1) {
@@ -179,28 +240,29 @@ async function flyToHurricane(storm) {
const lats = allCoords.map(c => c[1]);
map.fitBounds(
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
{ padding: 60, duration: 600, maxZoom: 5 }
{ padding: { top: 70, left: 50, right: 50, bottom: 260 }, duration: 600, maxZoom: 5 }
);
}
map.on('click', 'hurricane-track-pts', e => {
const p = e.features[0].properties;
const tau = p.TAU != null ? `+${p.TAU}h` : '';
const knots = p.MAXWIND || p.INTENSITY;
const wind = knots ? `${Math.round(knots * 1.15)} mph` : '--';
const type = p.TCDVLP || p.STORMTYPE || '';
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<b>${tau}</b> ${type}<br>Winds: ${wind}${p.MSLP ? '<br>Pressure: ' + p.MSLP + ' mb' : ''}`)
.addTo(map);
});
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
// Register the handlers once per map: re-adding them on every call stacked up duplicate popups
if (!map.__trackHandlers) {
map.__trackHandlers = true;
map.on('click', 'hurricane-track-pts', e => {
const p = e.features[0].properties;
const when = p.TAU != null ? (p.TAU === 0 ? 'Now' : `+${p.TAU}h`) : '';
const kt = Number(p.MAXWIND) || 0;
const cat = saffirCategory(kt);
const kind = cat ? `Category ${cat} hurricane` : kt >= 34 ? 'Tropical storm' : 'Tropical depression';
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<b>${escHtml(when)}</b> ${escHtml(kind)}<br>Winds: ${kt ? Math.round(kt * KT_TO_MPH) + ' mph (' + kt + ' kt)' : '--'}`)
.addTo(map);
});
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
}
}
} catch {}
}
// Render overlay last so storm circles sit on top of the track layers
await renderHurricaneOverlay(map);
}
function renderPrecipChart(h, startIdx) {
@@ -264,8 +326,50 @@ async function subscribePush() {
}
}
// Overlays used to bail out silently whenever tiles were still loading (most of the time while radar animates).
// Wait for the map to settle instead (but never hang: give up after a few seconds and try anyway).
function mapReady(map, timeoutMs = 4000) {
if (!map || map.loaded()) return Promise.resolve();
return new Promise(resolve => {
const t = setTimeout(resolve, timeoutMs);
map.once('idle', () => { clearTimeout(t); resolve(); });
});
}
// Infrared satellite from NASA GIBS (GOES-West + GOES-East, newest image, no key). Unlike ground radar it
// reaches open ocean, so a hurricane's cloud shield is visible. East draws over West where they overlap.
const GOES_LAYERS = [['goes-west', 'GOES-West_ABI_Band13_Clean_Infrared'], ['goes-east', 'GOES-East_ABI_Band13_Clean_Infrared']];
async function renderCloudOverlay(map) {
if (!map) return;
await mapReady(map);
const on = !!state.unifiedMapLayers.satellite;
const stamp = Math.floor(Date.now() / 600000); // GIBS updates ~every 10 min; bust the tile cache then
try {
GOES_LAYERS.forEach(([id, layer]) => {
if (!on) { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); return; }
if (map.getLayer(id)) { map.setLayoutProperty(id, 'visibility', 'visible'); }
else {
map.addSource(id, { type: 'raster', tileSize: 256, maxzoom: 6, attribution: 'NASA GIBS',
tiles: [`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png?t=${stamp}`] });
// sit under the animated radar frames so rain still reads on top of the clouds
const beforeId = map.getStyle().layers.find(l => l.id.startsWith('rv-radar-'))?.id;
map.addLayer({ id, type: 'raster', source: id, paint: { 'raster-opacity': 0.8, 'raster-fade-duration': 0 } }, beforeId);
}
if (map.__cloudStamp !== stamp && map.getSource(id)?.setTiles) {
map.getSource(id).setTiles([`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png?t=${stamp}`]);
}
});
if (on) map.__cloudStamp = stamp;
// Radar frames must always draw above the clouds (they may have been added after this layer), or rain disappears
map.getStyle().layers.filter(l => l.id.startsWith('rv-radar-')).forEach(l => map.moveLayer(l.id));
} catch { /* style swapped mid-update; next refresh retries */ }
}
async function renderHurricaneOverlay(map) {
if (!map || !map.loaded()) return;
if (!map) return;
const tok = (map.__hurrTok = (map.__hurrTok || 0) + 1); // a newer refresh supersedes this one
await mapReady(map);
if (tok !== map.__hurrTok) return;
['hurricane-overlay'].forEach(id => {
if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource(id)) map.removeSource(id);
@@ -273,17 +377,16 @@ async function renderHurricaneOverlay(map) {
if (!state.unifiedMapLayers.hurricane) return;
try {
const data = await fetch('/api/hurricanes').then(r => r.json());
if (tok !== map.__hurrTok) return;
const storms = data.storms || [];
if (!storms.length) return;
const features = storms.map(s => {
const lat = parseNhcCoord(s.latitude);
const lon = parseNhcCoord(s.longitude);
if (isNaN(lat) || isNaN(lon)) return null;
const cat = (s.classification || '').toUpperCase();
const i = stormInfo(s);
if (isNaN(i.lat) || isNaN(i.lon)) return null;
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { name: s.name || 'Storm', cat, intensity: s.intensity || '--' },
geometry: { type: 'Point', coordinates: [i.lon, i.lat] },
properties: { title: i.title, label: i.label, mph: i.mph, kt: i.kt, color: i.color, movement: i.movement },
};
}).filter(Boolean);
map.addSource('hurricane-overlay', { type: 'geojson', data: { type: 'FeatureCollection', features } });
@@ -291,25 +394,29 @@ async function renderHurricaneOverlay(map) {
id: 'hurricane-overlay', type: 'circle', source: 'hurricane-overlay',
paint: {
'circle-radius': 14,
'circle-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'],
'circle-color': ['get', 'color'],
'circle-opacity': 0.55,
'circle-stroke-width': 2.5,
'circle-stroke-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'],
'circle-stroke-color': ['get', 'color'],
},
});
map.on('click', 'hurricane-overlay', e => {
const p = e.features[0].properties;
const label = p.cat === 'HU' ? 'Hurricane' : p.cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<b>${p.name}</b><br>${label}<br>Winds: ${p.intensity} mph`).addTo(map);
});
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; });
// Handlers are registered once; this overlay is rebuilt on every map refresh
if (!map.__stormOverlayHandlers) {
map.__stormOverlayHandlers = true;
map.on('click', 'hurricane-overlay', e => {
const p = e.features[0].properties;
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<b>${escHtml(p.title)}</b><br>${escHtml(p.label)}<br>Winds: ${p.mph != null && p.mph !== 'null' ? p.mph + ' mph' : '--'}${p.movement ? '<br>' + escHtml(p.movement) : ''}`).addTo(map);
});
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; });
}
} catch {}
}
async function renderWildfireOverlay(map) {
if (!map || !map.loaded()) return;
if (!map) return;
await mapReady(map);
['wildfire-fill', 'wildfire-outline'].forEach(id => {
if (map.getLayer(id)) map.removeLayer(id);
});
@@ -334,7 +441,8 @@ async function renderWildfireOverlay(map) {
}
async function renderSPCOverlay(map) {
if (!map || !map.loaded()) return;
if (!map) return;
await mapReady(map);
['spc-overlay'].forEach(id => {
if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource(id)) map.removeSource(id);
@@ -386,7 +494,7 @@ const STORAGE_KEYS = {
showLightning: 'weather.showLightning',
showWind: 'weather.showWind',
showRadarLayer: 'weather.map.radar',
showSatelliteLayer: 'weather.map.satellite',
showSatelliteLayer: 'weather.map.goesIR', // new key: the old 'satellite' toggle never drew anything, ignore its stale value
showLightningLayer: 'weather.map.lightning',
showWindLayer: 'weather.map.wind',
showAqiLayer: 'weather.map.aqi',
@@ -432,13 +540,13 @@ const state = {
showExtendedForecast: false,
unifiedMapLayers: {
radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false',
satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) !== 'false',
lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false',
wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false',
aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false',
alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false',
temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false',
sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false',
satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) === 'true',
lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) === 'true',
wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) === 'true',
aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) === 'true',
alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) === 'true',
temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) === 'true',
sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) === 'true',
hurricane: localStorage.getItem(STORAGE_KEYS.showHurricaneLayer) === 'true',
wildfire: localStorage.getItem(STORAGE_KEYS.showWildfireLayer) === 'true',
spc: localStorage.getItem(STORAGE_KEYS.showSpcLayer) === 'true',
@@ -746,7 +854,7 @@ function renderSavedCities() {
// ── Tab switching ─────────────────────────────────────────────────────────────
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(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active'));
show(document.getElementById('tab-' + tabName));
@@ -768,7 +876,7 @@ function activateTab(tabName) {
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));
});
@@ -892,6 +1000,9 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
center: [-98.35, 39.5],
zoom: 4,
attributionControl: false,
// The embedded radar sits in a scrolling page: plain wheel / one-finger drag scroll the page,
// Ctrl+wheel / two fingers move the map.
cooperativeGestures: mapEl?.id === 'radar-embed',
});
let radarFrames = [];
@@ -941,7 +1052,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
function addFrameLayer(frame, forMode) {
const id = `rv-${forMode}-${frame.time}`;
if (!map.getSource(id)) {
map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256 });
map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256, ...(frame.maxNativeZoom ? { maxzoom: frame.maxNativeZoom } : {}) });
map.addLayer({
id, type: 'raster', source: id,
paint: {
@@ -1278,8 +1389,9 @@ function refreshMapsOverview() {
if (!state.currentCity) return;
const zoom = mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6;
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, zoom || 6);
// Keep the user's own pan/zoom; only (re)centre when the location changes
if (window.AppUI && window.AppUI.centerMap) window.AppUI.centerMap(state.currentCity);
else mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6);
clearLightningMarkers();
clearWindArrows();
@@ -1294,6 +1406,7 @@ function refreshMapsOverview() {
renderAlertMarker(state.currentCity);
renderTempMarker(state.currentCity);
renderSunMoonMarker(state.currentCity);
renderCloudOverlay(mapsOverview.map);
renderHurricaneOverlay(mapsOverview.map);
renderWildfireOverlay(mapsOverview.map);
renderSPCOverlay(mapsOverview.map);
@@ -1596,8 +1709,8 @@ function renderWeather(data, city) {
radarEmbed.panTo(city.latitude, city.longitude, 6);
radarFull.panTo(city.latitude, city.longitude, 6);
const mapZoom = mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6;
mapsOverview.panTo(city.latitude, city.longitude, mapZoom || 6);
if (window.AppUI && window.AppUI.centerMap) window.AppUI.centerMap(city);
else mapsOverview.panTo(city.latitude, city.longitude, (mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6) || 6);
if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
updateSourceLocation(city);
updateShareUrl(city);
@@ -1609,6 +1722,8 @@ function renderWeather(data, city) {
// Apply station overlay if we have fresh station data
if (state.ambientDevice) applyStationOverlay(state.ambientDevice);
if (window.AppUI) window.AppUI.onWeather(data, city);
}
function renderDailyForecast(daily) {
@@ -1619,16 +1734,22 @@ function renderDailyForecast(daily) {
const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays);
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++) {
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');
row.className = 'day-row' + (i === 0 ? ' today' : '');
row.innerHTML = `
<div class="day-name">${formatDayShort(daily.time[i])}</div>
<div class="day-icon">${dw.icon}</div>
<div class="day-desc">${dw.desc}</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-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-icon" title="${dw.desc}">${dw.icon}</div>
<div class="day-pop">${pop > 0 ? `<span class="pop-drop">💧</span>${pop}%` : ''}</div>
<div class="day-lo-t">${tempDisplay(lo)}°</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);
}
@@ -1661,6 +1782,7 @@ function renderAirQuality(data) {
if (panelAqi) panelAqi.textContent = cur.us_aqi ?? '--';
setMapOverlayReadouts({ aqi: cur.us_aqi ?? '--' });
if (window.AppUI) window.AppUI.onAir(data);
}
function normalizeSeverity(severity) {
@@ -1680,6 +1802,10 @@ function severityRank(severity) {
function plainAlertAction(eventName, severity) {
const eventText = String(eventName || '').toLowerCase();
const sev = normalizeSeverity(severity);
if (eventText.includes('storm surge')) return 'Know your evacuation zone. If officials order an evacuation, leave early: surge is the deadliest part of a tropical storm.';
if (eventText.includes('hurricane') || eventText.includes('typhoon')) return eventText.includes('warning') ? 'Finish storm preparations now, follow local evacuation orders, and plan to shelter until the storm has fully passed.' : 'Review your plan, stock water, food and medicine, secure outdoor items, and know your evacuation zone.';
if (eventText.includes('tropical')) return eventText.includes('warning') ? 'Expect damaging winds and heavy rain. Secure outdoor items, charge devices, and stay off the roads once conditions deteriorate.' : 'Tropical storm conditions are possible. Prepare now: secure outdoor items and keep supplies ready.';
if (eventText.includes('extreme wind')) return 'Take shelter immediately in the strongest part of a sturdy building, away from windows.';
if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.';
if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.';
if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.';
@@ -1694,10 +1820,9 @@ function plainAlertAction(eventName, severity) {
function buildAlertTiming(p) {
const start = p?.onset || p?.effective || p?.sent;
const end = p?.expires;
const startTxt = start ? formatClock(start) : 'now';
const endTxt = end ? formatClock(end) : 'until further notice';
return `${startTxt} - ${endTxt}`;
const end = p?.expires || p?.ends;
const fmt = iso => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `${start ? fmt(start) : 'now'} → ${end ? fmt(end) : 'until further notice'}`;
}
function buildStormEtaText() {
@@ -1889,13 +2014,60 @@ function showShareWeatherStatus(msg) {
}, 2200);
}
// One active alert: what it's for, where, when, what to do, and the full text on demand
function buildAlertCard(f) {
const p = f.properties || {};
const AI = window.AlertInfo;
const sev = normalizeSeverity(p.severity);
const meaning = AI.meaning(p.event);
const why = p.description ? AI.firstSentence(p.description) : (p.headline || 'See official sources for details.');
const areaAll = AI.area(p.areaDesc);
const parts = String(p.areaDesc || '').split(';').map(x => x.trim()).filter(Boolean);
const areaShort = parts.length > 4 ? `${parts.slice(0, 4).join(', ')} +${parts.length - 4} more` : areaAll;
const left = AI.remaining(p.expires || p.ends);
const todo = p.instruction ? AI.firstSentence(p.instruction, 260) : plainAlertAction(p.event, p.severity);
const meta = [p.severity && p.severity !== 'Unknown' && `Severity: ${p.severity}`, p.urgency && p.urgency !== 'Unknown' && `Urgency: ${p.urgency}`,
p.certainty && p.certainty !== 'Unknown' && `Certainty: ${p.certainty}`, p.senderName && `Issued by ${p.senderName}`].filter(Boolean);
const card = document.createElement('article');
card.className = 'alert-item alert-rich';
card.innerHTML = `
<div class="alert-top">
<strong>${AI.esc(p.event || 'Alert')}</strong>
<span class="alert-severity severity-${sev.toLowerCase()}">${sev}</span>
</div>
<div class="alert-why">${AI.esc(why)}</div>
<div class="alert-facts">
${areaShort ? `<span>📍 ${AI.esc(areaShort)}</span>` : ''}
<span>🕑 ${AI.esc(buildAlertTiming(p))}</span>
${left ? `<span class="alert-left">⏳ ${AI.esc(left)}</span>` : ''}
</div>
${meaning ? `<div class="alert-meaning"><b>What this means:</b> ${AI.esc(meaning)}</div>` : ''}
<div class="alert-action"><b>What to do:</b> ${AI.esc(todo)}</div>
<details class="alert-more">
<summary>Full details</summary>
<div class="alert-full">
${p.headline ? `<p><b>Headline:</b> ${AI.esc(p.headline)}</p>` : ''}
${areaAll ? `<p><b>Area:</b> ${AI.esc(areaAll)}</p>` : ''}
${p.description ? `<p class="alert-pre"><b>Details:</b>\n${AI.esc(p.description.trim())}</p>` : ''}
${p.instruction ? `<p class="alert-pre"><b>Instructions:</b>\n${AI.esc(p.instruction.trim())}</p>` : ''}
${meta.length ? `<p class="alert-meta">${meta.map(AI.esc).join(' · ')}</p>` : ''}
</div>
</details>`;
return card;
}
function renderAlerts(data) {
// Populate hurricane panel asynchronously
fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {});
const wrap = document.getElementById('alerts-wrap');
const list = document.getElementById('alerts-list');
const features = data?.features || [];
// Only alerts that are still active (a cached response can include ones that have just expired)
const features = (data?.features || []).filter(f => {
const end = Date.parse(f.properties?.expires || f.properties?.ends || '');
return !Number.isFinite(end) || end > Date.now();
});
list.innerHTML = '';
if (!features.length) {
@@ -1934,39 +2106,7 @@ function renderAlerts(data) {
head.appendChild(hint);
groupWrap.appendChild(head);
grouped.get(severity).slice(0, 6).forEach(f => {
const p = f.properties || {};
const card = document.createElement('article');
card.className = 'alert-item';
const top = document.createElement('div');
top.className = 'alert-top';
const event = document.createElement('strong');
event.textContent = p.event || 'Alert';
const sev = document.createElement('span');
sev.className = `alert-severity severity-${normalizeSeverity(p.severity).toLowerCase()}`;
sev.textContent = normalizeSeverity(p.severity);
top.appendChild(event);
top.appendChild(sev);
const timing = document.createElement('div');
timing.className = 'alert-timing';
timing.textContent = `Window: ${buildAlertTiming(p)}`;
const msg = document.createElement('div');
msg.className = 'alert-message';
msg.textContent = p.headline || p.description || 'See official sources for details.';
const action = document.createElement('div');
action.className = 'alert-action';
action.textContent = `What this means for me: ${plainAlertAction(p.event, p.severity)}`;
card.appendChild(top);
card.appendChild(timing);
card.appendChild(msg);
card.appendChild(action);
groupWrap.appendChild(card);
});
grouped.get(severity).forEach(f => groupWrap.appendChild(buildAlertCard(f)));
list.appendChild(groupWrap);
});
@@ -1985,10 +2125,10 @@ function renderAlerts(data) {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready.then(reg =>
reg.showNotification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
).catch(() => new Notification(title, { body, icon: '/icons/icon-192.png' }));
reg.showNotification(title, { body, icon: '/icons/v2/icon-192.png', badge: '/icons/v2/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
).catch(() => new Notification(title, { body, icon: '/icons/v2/icon-192.png' }));
} else {
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
new Notification(title, { body, icon: '/icons/v2/icon-192.png', badge: '/icons/v2/icon-192.png' });
}
}
if (state.ntfyTopic) {
@@ -2175,26 +2315,63 @@ searchBtn.addEventListener('click', doSearch);
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
// ── Geolocation ───────────────────────────────────────────────────────────────
locateBtn.addEventListener('click', () => {
// Why a location request failed, in plain language (the browser tells us: 1 denied, 2 unavailable, 3 timeout)
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const IS_STANDALONE = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
async function geoPermissionState() {
try { return (await navigator.permissions.query({ name: 'geolocation' })).state; } catch { return 'unknown'; }
}
function geoErrorHelp(err, perm) {
const code = err && err.code;
const tag = ` (error ${code ?? '?'}${perm && perm !== 'unknown' ? ', permission: ' + perm : ''})`;
if (code === 1) {
if (IS_IOS) {
const where = IS_STANDALONE
? 'Settings → Privacy & Security → Location Services → "NeCloud Weather" → While Using the App'
: 'Settings → Privacy & Security → Location Services → Safari Websites → While Using, then in Safari tap aA → Website Settings → Location → Allow';
return `Your phone says location is off for ${IS_STANDALONE ? 'this app' : 'this site'}. Turn it on in ${where}. If you use a Safari content blocker, also try aA → Turn Off Content Blockers for this site.${tag}`;
}
return `Location is blocked for this site. Click the lock icon in the address bar → Location → Allow, then try again.${tag}`;
}
if (code === 2) return `Your device couldn't find your position yet (no GPS or Wi-Fi fix). Make sure Location Services is on, turn off Low Power Mode, and try again, ideally on Wi-Fi or outdoors.${tag}`;
if (code === 3) return `Timed out waiting for your location. Try again in a moment.${tag}`;
return `Could not get your location.${tag}`;
}
function geoOnce(opts) {
return new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, opts));
}
// A slow or cold GPS often times out the first time: retry once with a patient, high-accuracy request.
// A real "denied" is final, so don't retry that.
async function getPositionRobust() {
try {
return await geoOnce({ enableHighAccuracy: false, timeout: 12000, maximumAge: 5 * 60 * 1000 });
} catch (e) {
if (e && e.code === 1) throw e;
return await geoOnce({ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
}
}
locateBtn.addEventListener('click', async () => {
if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
clearErr();
hide(weatherDiv);
show(loadingEl);
navigator.geolocation.getCurrentPosition(async pos => {
try {
const pos = await getPositionRobust();
const { latitude: lat, longitude: lon } = pos.coords;
try {
const city = await buildDetectedCity(lat, lon);
await loadAll(city, 'gps-button');
} catch {
showErr('Could not fetch weather for your location.');
} finally {
hide(loadingEl);
}
}, () => {
} catch (err) {
if (state.currentWeather) show(weatherDiv);
showErr(geoErrorHelp(err, await geoPermissionState()));
} finally {
hide(loadingEl);
showErr('Location access denied.');
});
if (window.AppUI && window.AppUI.refreshGeoStatus) window.AppUI.refreshGeoStatus();
}
});
unitsBtn.addEventListener('click', () => {
@@ -2437,20 +2614,26 @@ document.querySelectorAll('#tab-unified-map input[type="checkbox"][data-layer]')
if (!enabled && unifiedMap?.stopPlay) unifiedMap.stopPlay();
}
if (layerName === 'satellite') {
if (enabled) {
if (unifiedMapMode !== 'satellite') unifiedMapModeButton.click();
setCloudLayerVisible(true);
} else {
setCloudLayerVisible(false);
if (unifiedMapMode !== 'radar') unifiedMapModeButton.click();
}
}
if (layerName === 'satellite') renderCloudOverlay(mapsOverview.map);
syncReadoutChips();
if (state.currentCity) refreshMapsOverview();
});
});
// The top-left readout chips only show for layers the user switched on
function syncReadoutChips() {
const L = state.unifiedMapLayers;
[['overlay-temp', L.temp], ['overlay-aqi', L.aqi], ['overlay-sunmoon', L.sunMoon]].forEach(([id, on]) => {
const el = document.getElementById(id);
if (el) el.hidden = !on;
});
const box = document.getElementById('map-overlay-readouts');
if (box) box.hidden = !(L.temp || L.aqi || L.sunMoon);
}
syncReadoutChips();
const baseMapSelect = document.getElementById('base-map-select');
if (baseMapSelect) {
baseMapSelect.value = state.unifiedMapBaseLayer;
@@ -2476,12 +2659,14 @@ if (baseMapSelect) {
}
const layerMenuToggleBtn = document.getElementById('layer-menu-toggle');
document.getElementById('layer-menu-close')?.addEventListener('click', () => { if (!document.getElementById('layer-menu-panel').classList.contains('hidden')) document.getElementById('layer-menu-toggle').click(); });
const layerMenuPanel = document.getElementById('layer-menu-panel');
if (layerMenuToggleBtn && layerMenuPanel) {
layerMenuToggleBtn.addEventListener('click', () => {
const willShow = layerMenuPanel.classList.contains('hidden');
layerMenuPanel.classList.toggle('hidden', !willShow);
layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
if (!layerMenuToggleBtn.classList.contains('hud-btn')) layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
layerMenuToggleBtn.classList.toggle('on', willShow);
layerMenuToggleBtn.setAttribute('aria-expanded', willShow ? 'true' : 'false');
});
}
@@ -2658,7 +2843,7 @@ syncOfflineState();
// ── Swipe gestures for tab switching ──────────────────────────────────────────
(function initSwipe() {
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
const TABS = ['conditions', 'station', 'unified-map', 'alerts'];
let touchX = 0;
let touchY = 0;
let blocked = false;
@@ -2813,7 +2998,9 @@ if (ambientSaveBtn) {
}
// 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 (ambientStatusEl) { ambientStatusEl.textContent = '✓ Keys configured on server'; show(ambientStatusEl); }
fetchAmbientStation();
@@ -2968,10 +3155,7 @@ if (pwsSaveBtn) {
let stationRefreshTimer = null;
async function fetchAmbientStation() {
const loadingEl = document.getElementById('station-loading');
const contentEl = document.getElementById('station-content');
const errorEl = document.getElementById('station-error');
const peekEl = document.getElementById('station-drawer-peek');
const tabMsg = (kind, detail) => { if (typeof window.stationTabMessage === 'function') window.stationTabMessage(kind, detail); };
const apiKey = state.ambientApiKey;
const appKey = state.ambientAppKey;
@@ -2984,18 +3168,14 @@ async function fetchAmbientStation() {
const r = await fetch(`/api/ambient${qs}`);
if (!r.ok) {
const { error } = await r.json().catch(() => ({}));
if (errorEl) { errorEl.textContent = error || 'Station unavailable'; show(errorEl); }
if (loadingEl) hide(loadingEl);
tabMsg(r.status === 503 ? 'unconfigured' : 'error', error || 'Station unavailable');
return;
}
const devices = await r.json();
if (!Array.isArray(devices) || !devices.length) {
if (errorEl) { errorEl.textContent = 'No devices found on this account.'; show(errorEl); }
if (loadingEl) hide(loadingEl);
tabMsg('error', 'No devices found on this account.');
return;
}
if (loadingEl) hide(loadingEl);
if (errorEl) hide(errorEl);
renderAmbientStation(devices[0]);
// Auto-refresh every 60 s
@@ -3014,8 +3194,7 @@ async function fetchAmbientStation() {
} catch {}
}, 60000);
} catch (e) {
if (errorEl) { errorEl.textContent = 'Could not reach station.'; show(errorEl); }
if (loadingEl) hide(loadingEl);
tabMsg('error', 'Could not reach your station.');
}
}
@@ -3075,14 +3254,6 @@ function applyStationOverlay(device) {
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
const rainEl = document.getElementById('cur-precip');
if (rainEl && data.dailyrainin != null) {
@@ -3099,87 +3270,19 @@ function applyStationOverlay(device) {
peekEl.textContent = `· ${fS(data.windspeedmph)} ${speedUnit()} ${compassDir(data.winddir || 0)} · ${data.humidity ?? '--'}% humidity`;
}
// Replace location name with station name
const stationName = device.info?.name || device.macAddress || '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)
// Keep the searched location's name; mark that temperatures now come from your own station
const stationName = (state.stationName || device.info?.name || 'My Station');
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) {
state.ambientDevice = device;
const data = device?.lastData || {};
const info = device?.info || {};
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);
// The Station tab (station-live.js) renders the readings
if (typeof window.renderStationTab === 'function') window.renderStationTab(device);
// Update the main conditions display with real station readings
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>
+224
View File
@@ -0,0 +1,224 @@
<!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">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
<meta name="apple-mobile-web-app-title" content="Station">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/pwa-safe.css">
<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/settings" class="nav-link">Settings</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 class="section" id="installSection">
<div class="section-hdr">Install this dashboard as an app</div>
<p class="hint" style="margin:0 0 0.7rem" id="installDone" hidden>✓ You're using the installed app.</p>
<div id="installSteps">
<p class="hint" style="margin:0 0 0.4rem"><b>iPhone / iPad (Safari):</b> tap the <b>Share</b> button, then <b>Add to Home Screen</b>, then <b>Add</b>.</p>
<p class="hint" style="margin:0"><b>Android (Chrome):</b> tap the <b>⋮ menu</b>, then <b>Install app</b> (or <b>Add to Home screen</b>), then <b>Install</b>.</p>
<p class="hint">Open the app from its Home Screen icon for full screen. The weather app at <b>/</b> installs separately the same way.</p>
</div>
</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); }
});
if (window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true) {
$('installDone').hidden = false; $('installSteps').hidden = 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>
+17 -5
View File
@@ -2,18 +2,29 @@
<html lang="en">
<head>
<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="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --paper: #f5f2ec; --paper-dark: #ece7db;
--border: #b9ae9b; --accent: #c25a2a; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
--ink: #0d1b2a; --paper: #edf2f7; --paper-dark: #ffffff;
--border: #ccd6e2; --accent: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
}
@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); }
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
.sub { color: var(--border); font-size: 0.85rem; margin-bottom: 2rem; }
@@ -42,6 +53,7 @@ button:hover { opacity: 0.88; }
</div>
<script>
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';
</script>
</body>
+217
View File
@@ -0,0 +1,217 @@
/* Records tab: plain-language station records. Reads /api/station/records and /api/station/daily. */
(() => {
const root = document.getElementById('recordsRoot');
if (!root) return;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const DAY_MS = 86400000;
const dayMs = (d) => Date.parse(d + 'T12:00:00Z');
const fmtDay = (d) => new Date(dayMs(d)).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
const fmtTime = (ts) => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const localDayKey = (ts) => new Date(ts).toLocaleDateString('en-CA');
const today = () => localDayKey(Date.now());
const daysBetween = (d) => Math.round((dayMs(today()) - dayMs(d)) / DAY_MS);
const agoText = (d) => {
const n = daysBetween(d);
if (n <= 0) return 'today';
if (n === 1) return 'yesterday';
if (n < 14) return n + ' days ago';
if (n < 60) return Math.round(n / 7) + ' weeks ago';
return Math.round(n / 30.4) + ' months ago';
};
// Plain-English labels for numbers people can't judge on their own
const gustLabel = (v) => v < 19 ? 'Breezy' : v < 32 ? 'Windy' : v < 47 ? 'Strong wind' : v < 64 ? 'Gale' : v < 74 ? 'Storm force' : 'Hurricane force';
const uvLabel = (v) => v < 3 ? 'Low' : v < 6 ? 'Moderate' : v < 8 ? 'High' : v < 11 ? 'Very high' : 'Extreme';
const rateLabel = (v) => v < 0.1 ? 'Light rain' : v < 0.3 ? 'Moderate rain' : v < 2 ? 'Heavy rain' : 'Violent downpour';
const deg = (v) => v.toFixed(1) + '°F';
const inch = (v) => v.toFixed(2) + '"';
const mph = (v) => v.toFixed(1) + ' mph';
// Each record: where it comes from, how to say it, and what it means to someone new to weather data.
// field/kind drive the "runner-up" line and "today vs record" line from the per-day table.
const RECORDS = [
{ theme: 'Temperature', icon: '🔥', title: 'Hottest', why: 'The highest air temperature your station has measured.', field: 'hi', kind: 'max', fmt: deg, today: 'high' },
{ theme: 'Temperature', icon: '🥶', title: 'Coldest', why: 'The lowest air temperature your station has measured.', field: 'lo', kind: 'min', fmt: deg, today: 'low' },
{ theme: 'Temperature', icon: '🌤', title: 'Coolest daytime high', why: 'The day that never warmed up: its high was the lowest of any day.', field: 'hi', kind: 'min', fmt: deg, day: (r) => r.notable?.coolestHigh },
{ theme: 'Temperature', icon: '🌙', title: 'Warmest night', why: 'The night that never cooled off: its low was the highest of any day.', field: 'lo', kind: 'max', fmt: deg, day: (r) => r.notable?.warmestLow },
{ theme: 'Rain', icon: '🌧', title: 'Wettest day', why: 'The most rain that fell in a single day (midnight to midnight).', field: 'rain', kind: 'max', fmt: inch, day: (r) => r.notable?.wettestDay, today: 'rain' },
{ theme: 'Rain', icon: '⛈', title: 'Heaviest downpour', why: 'How fast rain fell at its hardest moment, in inches per hour.', field: 'rate', kind: 'max', fmt: (v) => v.toFixed(2) + ' in/hr', tag: rateLabel, val: (r) => r.highRainRate, ts: (r) => r.highRainRateDate },
{ theme: 'Wind', icon: '💨', title: 'Strongest gust', why: 'The fastest short burst of wind. Gusts last only a few seconds.', field: 'gust', kind: 'max', fmt: mph, tag: gustLabel, val: (r) => r.highGust, ts: (r) => r.highGustDate, today: 'gust' },
{ theme: 'Sun', icon: '☀️', title: 'Strongest sunshine', why: 'Peak sunlight power. Around 1,000 W/m² is about the most you get on a clear summer day.', field: 'solar', kind: 'max', fmt: (v) => Math.round(v).toLocaleString() + ' W/m²', val: (r) => r.highSolar, ts: (r) => r.highSolarDate },
{ theme: 'Sun', icon: '🧴', title: 'Highest UV index', why: 'How strong the sun\'s burning rays were. 3 and up is when sunscreen starts to matter.', field: 'uv', kind: 'max', fmt: (v) => String(Math.round(v)), tag: uvLabel, val: (r) => r.highUV, ts: (r) => r.highUVDate },
{ theme: 'Air pressure', icon: '📈', title: 'Highest pressure', why: 'High pressure usually means calm, clear weather.', field: 'pmax', kind: 'max', fmt: (v) => v.toFixed(2) + ' inHg', val: (r) => r.highPressure, ts: (r) => r.highPressureDate },
{ theme: 'Air pressure', icon: '📉', title: 'Lowest pressure', why: 'Low pressure usually comes with storms and rain. The lower it goes, the stronger the storm.', field: 'pmin', kind: 'min', fmt: (v) => v.toFixed(2) + ' inHg', val: (r) => r.lowPressure, ts: (r) => r.lowPressureDate },
{ theme: 'Humidity', icon: '💧', title: 'Most humid', why: 'How much moisture was in the air. 100% means the air can\'t hold any more (fog, dew, or a muggy day).', field: 'hmax', kind: 'max', fmt: (v) => Math.round(v) + '%', val: (r) => r.highHumidity, ts: (r) => r.highHumidityDate },
{ theme: 'Humidity', icon: '🏜', title: 'Driest air', why: 'The lowest moisture in the air. Under 30% feels dry: static shocks and dry skin.', field: 'hmin', kind: 'min', fmt: (v) => Math.round(v) + '%', val: (r) => r.lowHumidity, ts: (r) => r.lowHumidityDate },
];
// Resolve a record definition to concrete numbers/dates (or null when the station hasn't measured it yet)
function resolve(def, r) {
if (def.day) {
const o = def.day(r);
return o ? { value: o.value, day: o.day, ts: null } : null;
}
const value = def.val ? def.val(r) : (def.field === 'hi' ? r.highTemp : def.field === 'lo' ? r.lowTemp : null);
const ts = def.ts ? def.ts(r) : (def.field === 'hi' ? r.highTempDate : r.lowTempDate);
if (value == null || !ts) return null;
return { value, ts, day: localDayKey(ts) };
}
function runnersUp(def, daily, bestDay) {
const rows = daily.filter((d) => d[def.field] != null && d.d !== bestDay);
rows.sort((a, b) => (def.kind === 'max' ? b[def.field] - a[def.field] : a[def.field] - b[def.field]));
return rows.slice(0, 2);
}
function todayLine(def, rec, daily) {
if (!def.today || !daily.length) return '';
const row = daily[daily.length - 1];
if (row.d !== today()) return '';
const key = { high: 'hi', low: 'lo', rain: 'rain', gust: 'gust' }[def.today];
const v = row[key];
if (v == null) return '';
const diff = Math.abs(rec.value - v);
const gap = def.fmt === deg ? diff.toFixed(1) + '°' : def.fmt === inch ? diff.toFixed(2) + '"' : diff.toFixed(1) + ' mph';
const beyond = def.kind === 'max' ? v >= rec.value : v <= rec.value;
const where = beyond ? 'at the record' : (def.kind === 'max' ? gap + ' below the record' : gap + ' above the record');
const label = { high: 'Today\'s high so far', low: 'Today\'s low so far', rain: 'Rain so far today', gust: 'Today\'s strongest gust' }[def.today];
return `<div class="rc-today">${label}: <b>${def.fmt(v)}</b> · ${where}</div>`;
}
function card(def, r, daily) {
const rec = resolve(def, r);
if (!rec) return null;
const isNew = daysBetween(rec.day) <= 14;
const when = rec.ts && def.field !== 'rain'
? `${fmtDay(rec.day)} at ${fmtTime(rec.ts)}`
: fmtDay(rec.day);
const ru = runnersUp(def, daily, rec.day);
const ruLine = ru.length
? `<div class="rc-next">Next closest: ${ru.map((x) => `${def.fmt(x[def.field])} <span>(${new Date(dayMs(x.d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })})</span>`).join(' · ')}</div>`
: '';
return `<div class="rc${isNew ? ' rc-new' : ''}">
<div class="rc-top"><span class="rc-ico" aria-hidden="true">${def.icon}</span><span class="rc-title">${def.title}</span>${isNew ? '<span class="rc-badge">NEW</span>' : ''}</div>
<div class="rc-val">${def.fmt(rec.value)}${def.tag ? `<span class="rc-tag">${def.tag(rec.value)}</span>` : ''}</div>
<div class="rc-when">${when}<span> · ${agoText(rec.day)}</span></div>
<div class="rc-why">${def.why}</div>
${todayLine(def, rec, daily)}${ruLine}
</div>`;
}
function monthStrip(r) {
const have = new Set((r.months || []).map((m) => +m.month.slice(5, 7)));
const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `<div class="rc-months" role="img" aria-label="Months with recorded data">${names.map((n, i) => `<span class="${have.has(i + 1) ? 'on' : ''}">${n}</span>`).join('')}</div>`;
}
function intro(r) {
const missing = 12 - new Set((r.months || []).map((m) => +m.month.slice(5, 7))).size;
return `<div class="rec-intro">
<h2>Your station's records</h2>
<p><b>A record is the most extreme weather your station has measured.</b> Your station has been recording for <b>${r.daysRecorded} days</b> (since ${fmtDay(r.firstDay)}), so these are records <i>so far</i>. They will change as the seasons go by.</p>
${monthStrip(r)}
<p class="rec-note">${missing > 0 ? `Highlighted months have data. The other ${missing} will fill in over time, so winter cold and summer heat records are not final until your station has been through those seasons. ` : ''}A <span class="rc-badge">NEW</span> badge means the record was set in the last 14 days.</p>
</div>`;
}
function monthTable(r) {
const months = r.months || [];
if (!months.length) return '';
const P = r.periods?.months || {};
const chips = {};
if (months.length >= 2) {
const add = (rec, chip) => { if (rec) (chips[rec.key] = chips[rec.key] || []).push(chip); };
add(P.hottest, '🔥 hottest month'); add(P.coldest, '❄️ coldest month');
add(P.wettest, '🌧 wettest month'); add(P.driest, '🏜 driest month');
}
const lo = Math.min(...months.map((m) => m.lo).filter((v) => v != null));
const hi = Math.max(...months.map((m) => m.hi).filter((v) => v != null));
const maxRain = Math.max(0.01, ...months.map((m) => m.rain));
const span = Math.max(1, hi - lo);
const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
const LIMIT = 12;
const rows = [...months].reverse().map((m, idx) => {
const total = dim(m.month), partial = m.days < total * 0.9;
const left = m.lo != null ? ((m.lo - lo) / span) * 100 : 0;
const width = m.lo != null && m.hi != null ? Math.max(3, ((m.hi - m.lo) / span) * 100) : 0;
return `<tr${idx >= LIMIT ? ' class="rec-extra" hidden' : ''}>
<td><b>${longMonth(m.month)}</b>${(chips[m.month] || []).map((c) => `<span class="rec-chip">${c}</span>`).join('')}${partial ? `<span class="rec-yr">${m.days} of ${total} days so far</span>` : ''}</td>
<td>${m.avg != null ? m.avg.toFixed(1) + '°F' : '—'}</td>
<td class="rec-rng"><span>${m.lo != null ? Math.round(m.lo) : '—'}° to ${m.hi != null ? Math.round(m.hi) : '—'}°</span><i class="rec-track"><i style="left:${left.toFixed(1)}%;width:${width.toFixed(1)}%"></i></i></td>
<td class="rec-rain"><span>${m.rain.toFixed(2)}"</span><i class="rec-track r"><i style="width:${((m.rain / maxRain) * 100).toFixed(1)}%"></i></i></td>
<td>${m.gust != null ? Math.round(m.gust) + ' mph' : '—'}</td></tr>`;
}).join('');
return `<h3 class="rec-h2">Month by month</h3>
<p class="rec-sub2">How each month compared. The temperature bar shows the coldest to hottest reading that month, on one shared scale.</p>
<div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Month</th><th>Average temp</th><th>Coldest to hottest</th><th>Total rain</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>${months.length > LIMIT ? `<button type="button" class="rec-more" data-more="month">Show all ${months.length} months</button>` : ''}`;
}
function yearBlock(r) {
const years = r.years || [];
if (!years.length) return '';
if (years.length === 1) {
const y = years[0];
return `<h3 class="rec-h2">${y.year} so far</h3>
<p class="rec-sub2">${y.days} days recorded: hottest <b>${y.hi != null ? deg(y.hi) : '—'}</b>, coldest <b>${y.lo != null ? deg(y.lo) : '—'}</b>, <b>${y.rain.toFixed(2)}"</b> of rain, strongest gust <b>${y.gust != null ? Math.round(y.gust) + ' mph' : '—'}</b>. Year-against-year comparisons appear once there is a second year of data.</p>`;
}
const rows = [...years].reverse().map((y) => `<tr><td><b>${y.year}</b><span class="rec-yr">${y.days} days</span></td><td>${y.avg != null ? y.avg.toFixed(1) + '°F' : '—'}</td><td>${y.hi != null ? deg(y.hi) : '—'}</td><td>${y.lo != null ? deg(y.lo) : '—'}</td><td>${y.rain.toFixed(2)}"</td><td>${y.gust != null ? Math.round(y.gust) + ' mph' : '—'}</td></tr>`).join('');
return `<h3 class="rec-h2">Year by year</h3><div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Year</th><th>Average temp</th><th>Hottest</th><th>Coldest</th><th>Total rain</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
// Only meaningful once a calendar month has been seen in more than one year
function sameMonthTable(r) {
const cm = (r.calendarMonths || []).map((c, i) => ({ c, i })).filter((x) => x.c.years > 1);
if (!cm.length) return '';
const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
const cell = (x, f) => x ? `<b>${f(x.value)}</b><span class="rec-yr">${fmtDay(x.day)}</span>` : '—';
const rows = cm.map(({ c, i }) => `<tr><td><b>${mn(i)}</b><span class="rec-yr">${c.years} years of data</span></td><td>${cell(c.hi, deg)}</td><td>${cell(c.lo, deg)}</td><td>${cell(c.wettestDay, inch)}</td><td>${cell(c.gust, mph)}</td></tr>`).join('');
return `<h3 class="rec-h2">Records for each month of the year</h3>
<p class="rec-sub2">The most extreme day for each calendar month, from any year. For example, the hottest July ever recorded.</p>
<div class="rec-scroll"><table class="rec-table2"><thead><tr><th>Month</th><th>Hottest day</th><th>Coldest</th><th>Wettest day</th><th>Strongest gust</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
async function load() {
try {
const [rr, dr] = await Promise.all([fetch('/api/station/records'), fetch('/api/station/daily').catch(() => null)]);
if (!rr.ok) { root.innerHTML = '<div class="status">No records yet.</div>'; return; }
const r = await rr.json();
if (!r) { root.innerHTML = '<div class="status">No data recorded yet.</div>'; return; }
let daily = [];
try { if (dr && dr.ok) daily = await dr.json(); } catch {}
const themes = [...new Set(RECORDS.map((d) => d.theme))];
const empty = [];
const groups = themes.map((t) => {
const cards = RECORDS.filter((d) => d.theme === t).map((d) => {
const html = card(d, r, daily);
if (!html) empty.push(d.title);
return html;
}).filter(Boolean);
return cards.length ? `<h3 class="rec-h2">${t}</h3><div class="rc-grid">${cards.join('')}</div>` : '';
}).join('');
root.innerHTML = intro(r) + groups
+ (empty.length ? `<p class="rec-note">Not measured yet: ${empty.join(', ')}. These show up once the station records something for them.</p>` : '')
+ monthTable(r) + yearBlock(r) + sameMonthTable(r)
+ `<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>`;
root.querySelectorAll('.rec-more').forEach((b) => b.addEventListener('click', () => {
const open = b.dataset.open !== '1';
b.dataset.open = open ? '1' : '';
root.querySelectorAll('.rec-extra').forEach((tr) => { tr.hidden = !open; });
b.textContent = open ? 'Show fewer months' : b.dataset.label;
}));
root.querySelectorAll('.rec-more').forEach((b) => { b.dataset.label = b.textContent; });
} catch {
root.innerHTML = '<div class="status error">Failed to load records.</div>';
}
}
load();
})();
+17 -6
View File
@@ -2,16 +2,26 @@
<html lang="en">
<head>
<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="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
<meta name="apple-mobile-web-app-title" content="Station">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard — Recover Access</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --paper: #f5f2ec; --paper-dark: #ece7db;
--border: #b9ae9b; --accent: #c25a2a; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
--ink: #0d1b2a; --paper: #edf2f7; --paper-dark: #ffffff;
--border: #ccd6e2; --accent: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
}
@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; }
.card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(400px, 94vw); }
@@ -27,6 +37,7 @@ button:hover { opacity: 0.88; }
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
.back { display: block; text-align: center; margin-top: 1.25rem; font-size: 0.85rem; color: var(--border); text-decoration: none; }
.back:hover { color: var(--ink); }
html, body { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
</style>
</head>
<body>
@@ -38,12 +49,12 @@ button:hover { opacity: 0.88; }
<code>Dashboard recovery token:</code><br>
In CasaOS, open the app's log from the container details panel. The token changes daily.
</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">
<label>Recovery Token</label>
<input name="token" type="text" autocomplete="off" spellcheck="false" autofocus required placeholder="Paste token from server logs">
<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>
</form>
<a href="/dashboard/login" class="back">← Back to sign in</a>
+66 -41
View File
@@ -2,26 +2,36 @@
<html lang="en">
<head>
<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="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
<meta name="apple-mobile-web-app-title" content="Station">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Settings</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649;
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7;
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a;
--ink: #0d1b2a; --ink-mid: #2b3f55; --ink-light: #5a7089;
--paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
--border: #ccd6e2; --accent: #2563eb; --cool: #0e7490;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399;
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620;
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8;
--ink: #dce6f0; --ink-mid: #b7c6d6; --ink-light: #8aa2b8;
--paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29;
--border: #22405c; --accent: #60a5fa; --cool: #38bdf8;
}
}
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
.header { display: flex; align-items: center; justify-content: space-between; padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1.5rem + env(safe-area-inset-right, 0px)) 1rem calc(1.5rem + env(safe-area-inset-left, 0px)); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
.header h1 { font-size: 1.15rem; }
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
@@ -88,6 +98,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
<div><h1>Settings</h1></div>
<div class="header-right">
<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>
</div>
</div>
@@ -135,7 +146,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
<div class="section">
<div class="section-hdr">Station Display</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">
</div>
<div class="settings-actions">
@@ -158,6 +169,18 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
</div>
</div>
<!-- NWS reference station -->
<div class="section">
<div class="section-hdr">NWS Reference Station</div>
<p class="hint" style="margin:0 0 0.85rem">Logs every official observation from the National Weather Service station nearest this ZIP code (plus hourly forecast snapshots and alerts) and overlays it on your charts.</p>
<div class="threshold-row">
<div><label>ZIP code</label><input id="nwsZip" type="text" inputmode="numeric" maxlength="5" placeholder="24550" autocomplete="off"></div>
<div style="align-self:end"><button class="primary" id="saveNwsBtn">Save &amp; look up</button></div>
</div>
<div class="settings-actions"><span id="nwsMsg" class="save-msg"></span></div>
<div id="nwsInfo" class="hint"></div>
</div>
<!-- Export / Import -->
<div class="section">
<div class="section-hdr">Data</div>
@@ -204,17 +227,12 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
</div>
</div>
<!-- Change Password -->
<!-- Account -->
<div class="section">
<div class="section-hdr">Change Password</div>
<div class="settings-grid">
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
</div>
<div class="settings-actions">
<button class="primary" id="changePwBtn">Change Password</button>
<span id="pwMsg" class="save-msg"></span>
</div>
<div class="section-hdr">Account &amp; Users</div>
<p class="hint" style="margin:0 0 0.85rem">Change your password and manage who can sign in (admin and viewer accounts).</p>
<a class="export-link" href="/dashboard/account">Open account &amp; users →</a>
</div>
</div>
</div>
@@ -413,28 +431,6 @@ async function pollBackfill() {
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 ─────────────────────────────────────────────────────────────
window.addEventListener('scroll', () => {
@@ -446,6 +442,35 @@ checkKeyStatus();
loadThresholds();
loadStationName();
loadLocation();
// ── NWS reference station ──────────────────────────────────────────────
async function loadNwsInfo() {
try {
const r = await fetch('/api/nws/config');
if (!r.ok) return;
const c = await r.json();
const zipEl = document.getElementById('nwsZip');
if (zipEl && !zipEl.value) zipEl.value = c.zip || '';
const res = c.resolved;
const when = (t) => t ? new Date(t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
document.getElementById('nwsInfo').innerHTML = res
? `Station <b>${res.stationId}</b> — ${res.stationName} · ${res.distanceMi ?? '?'} mi from ZIP ${res.zip}${res.place ? ' (' + res.place + ')' : ''}${res.approx ? ' · <i>approximate ZIP location</i>' : ''}<br>${c.observations.toLocaleString()} observations logged · first ${when(c.firstObservation)} · latest ${when(c.lastObservation)}${c.lastError ? '<br><span style="color:var(--accent)">Last error: ' + c.lastError + '</span>' : ''}`
: (c.lastError ? `<span style="color:var(--accent)">${c.lastError}</span>` : 'Looking up the station for this ZIP…');
} catch {}
}
document.getElementById('saveNwsBtn')?.addEventListener('click', async () => {
const msg = document.getElementById('nwsMsg'), zip = document.getElementById('nwsZip').value.trim();
msg.textContent = 'Looking up…'; msg.className = 'save-msg';
try {
const r = await fetch('/api/nws/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ zip }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Failed');
msg.textContent = 'Saved — logging has started'; await loadNwsInfo();
setTimeout(loadNwsInfo, 4000);
} catch (e) { msg.textContent = e.message; msg.className = 'save-msg err'; }
setTimeout(() => { msg.textContent = ''; }, 6000);
});
loadNwsInfo();
</script>
</body>
</html>
+18 -7
View File
@@ -2,16 +2,26 @@
<html lang="en">
<head>
<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="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/dashboard.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
<meta name="apple-mobile-web-app-title" content="Station">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard — Create Account</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --paper: #f5f2ec; --paper-dark: #ece7db;
--border: #b9ae9b; --accent: #c25a2a; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
--ink: #0d1b2a; --paper: #edf2f7; --paper-dark: #ffffff;
--border: #ccd6e2; --accent: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', sans-serif;
}
@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; }
.card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 16px; padding: 2.5rem 2rem; width: min(380px, 94vw); }
@@ -24,18 +34,19 @@ button { width: 100%; padding: 0.7rem; background: var(--accent); color: #fff; b
button:hover { opacity: 0.88; }
.error { background: rgba(194,90,42,0.15); border: 1px solid var(--accent); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 1rem; font-size: 0.9rem; color: var(--accent); }
.note { margin-top: 1rem; font-size: 0.8rem; opacity: 0.55; text-align: center; }
html, body { padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
</style>
</head>
<body>
<div class="card">
<h1>Create Dashboard Account</h1>
<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">
<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>
<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>
</form>
<p class="note">This account protects your station dashboard. Store these credentials somewhere safe.</p>
+208
View File
@@ -0,0 +1,208 @@
/* 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); }
/* Per-chart controls */
.chart-ctl { display: inline-flex; flex-wrap: wrap; gap: 0.4rem 0.8rem; align-items: center; text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 0.74rem; color: var(--ink-light); }
.chart-ctl label { display: inline-flex; align-items: center; gap: 0.35rem; }
.chart-ctl select { font: inherit; font-size: 0.76rem; padding: 0.25rem 0.4rem; min-height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); }
@media (pointer: coarse) { .chart-ctl select { font-size: 16px; min-height: 40px; } }
.chart-card .chart-title { flex-wrap: wrap; row-gap: 0.4rem; }
/* Month / year record cards: the period is the headline */
.record-period { font-size: 0.92rem; font-weight: 800; color: var(--ink); letter-spacing: -0.005em; margin-top: 0.1rem; }
.rec-sub { text-transform: none; letter-spacing: 0; font-weight: 400; opacity: .75; }
.rec-cal td { vertical-align: top; line-height: 1.25; }
.rec-cal td b { display: block; font-variant-numeric: tabular-nums; }
.rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; }
.rec-cal tr.empty { opacity: .4; }
/* Alert history: what / where / when */
.ah-what { font-size: 0.86rem; line-height: 1.4; }
.ah-meta { margin-top: 0.25rem; font-size: 0.74rem; color: var(--ink-light); line-height: 1.5; }
.ah-sev { display: inline-block; padding: 0 0.45rem; border-radius: 99px; font-weight: 700; font-size: 0.68rem; border: 1px solid var(--border); }
.ah-sev.sev-extreme { color: #ef4444; border-color: #ef4444; } .ah-sev.sev-severe { color: #f97316; border-color: #f97316; }
.ah-sev.sev-moderate { color: #eab308; border-color: #eab308; } .ah-sev.sev-minor { color: var(--cool); border-color: var(--cool); }
.ah-row { cursor: pointer; }
.ah-row.open td { background: color-mix(in srgb, var(--ui) 6%, transparent); }
.ah-full { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px dashed var(--border); font-size: 0.82rem; line-height: 1.5; cursor: default; }
.ah-full p { margin: 0 0 0.5rem; }
.ah-desc { white-space: pre-wrap; }
.ah-meaning { padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ui) 10%, transparent); }
.ah-note { font-size: 0.74rem; color: var(--ink-light); }
/* NWS overlay + card */
.nws-toggle.active { background: #7c5cff !important; border-color: #7c5cff !important; }
.nws-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #a78bfa; margin-left: 0.35rem; vertical-align: middle; }
.nws-card .kv b { font-size: 1.05rem; }
.nws-raw { display: block; margin-top: 0.4rem; font-size: 0.7rem; overflow-wrap: anywhere; color: var(--ink-light); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.35rem 0.55rem; }
/* Performance on phones: blur only the two big fixed bars, lightly */
.logout, .range-btn, .refresh-btn, .chart-card, .stat, .today-bar { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
.header, .tabbar { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
/* ── Records tab (plain-language) ── */
.rec2 { max-width: 1100px; margin: 0 auto; }
.rec-intro { background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem 1.2rem; margin-top: 1rem; }
.rec-intro h2 { font-size: 1.15rem; margin-bottom: 0.45rem; }
.rec-intro p { font-size: 0.9rem; line-height: 1.55; color: var(--ink-mid); }
.rec-note { font-size: 0.8rem !important; color: var(--ink-light) !important; margin-top: 0.7rem; line-height: 1.5; }
.rec2 > .rec-note { margin: 0.9rem 0.2rem 0; }
.rc-months { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; margin-top: 0.85rem; }
.rc-months span { text-align: center; font-size: 0.68rem; font-weight: 600; padding: 0.3rem 0; border-radius: 6px; background: var(--paper-darker); color: var(--ink-light); opacity: 0.55; }
.rc-months span.on { background: var(--accent); color: #fff; opacity: 1; }
.rec-h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-light); margin: 1.6rem 0.2rem 0.6rem; }
.rec-sub2 { font-size: 0.85rem; line-height: 1.55; color: var(--ink-mid); margin: -0.2rem 0.2rem 0.7rem; }
.rc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; }
.rc { background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.95rem 1.05rem; display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.rc-new { border-color: #d4a017; box-shadow: 0 0 0 1px #d4a01766; }
.rc-top { display: flex; align-items: center; gap: 0.5rem; }
.rc-ico { font-size: 1.15rem; line-height: 1; }
.rc-title { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-mid); }
.rc-badge { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.08em; padding: 0.12rem 0.4rem; border-radius: 999px; background: #d4a017; color: #1b1400; }
.rc-top .rc-badge { margin-left: auto; }
.rc-val { font-size: 1.9rem; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.2rem 0.6rem; }
.rc-tag { font-size: 0.74rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; background: var(--paper-darker); color: var(--ink-mid); }
.rc-when { font-size: 0.82rem; font-weight: 600; color: var(--ink); }
.rc-when span { font-weight: 400; color: var(--ink-light); }
.rc-why { font-size: 0.8rem; line-height: 1.5; color: var(--ink-light); }
.rc-today, .rc-next { font-size: 0.78rem; color: var(--ink-mid); padding-top: 0.45rem; margin-top: 0.15rem; border-top: 1px dashed var(--border); }
.rc-next { border-top: none; padding-top: 0; margin-top: 0; color: var(--ink-light); }
.rc-next span { opacity: 0.8; }
.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--paper-dark); border: 1px solid var(--border); border-radius: var(--radius); }
.rec-table2 { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
.rec-table2 th, .rec-table2 td { text-align: left; padding: 0.7rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.rec-table2 tr:last-child td { border-bottom: none; }
.rec-table2 th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); font-weight: 700; white-space: nowrap; }
.rec-table2 td { font-variant-numeric: tabular-nums; }
.rec-table2 td:first-child { min-width: 9.5rem; }
.rec-table2 .rec-yr { display: block; font-size: 0.72rem; color: var(--ink-light); font-weight: 400; margin-top: 0.15rem; }
.rec-chip { display: inline-block; margin: 0.25rem 0.3rem 0 0; font-size: 0.68rem; font-weight: 600; padding: 0.12rem 0.5rem; border-radius: 999px; background: var(--paper-darker); color: var(--ink-mid); white-space: nowrap; }
.rec-rng, .rec-rain { min-width: 9rem; }
.rec-rng > span, .rec-rain > span { display: block; margin-bottom: 0.35rem; }
.rec-track { display: block; position: relative; height: 7px; border-radius: 99px; background: var(--paper-darker); }
.rec-track > i { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: linear-gradient(90deg, var(--cool), var(--accent)); }
.rec-track.r > i { left: 0; background: var(--cool); }
@media (max-width: 640px) {
.rc-grid { grid-template-columns: 1fr; }
.rc-val { font-size: 1.7rem; }
.rec-table2 th, .rec-table2 td { padding: 0.6rem 0.65rem; }
}
.rec-extra[hidden] { display: none; }
.rec-more { display: block; margin: 0.7rem auto 0; min-height: 40px; padding: 0 1.1rem; border: 1px solid var(--border); border-radius: 999px; background: var(--paper-dark); color: var(--ink); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
+653
View File
@@ -0,0 +1,653 @@
/* 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 === 'charts' && typeof chartsDirty !== 'undefined' && chartsDirty && typeof loadHistory === 'function') loadHistory(currentRange);
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;
// The julian-day rounding above can land on the neighbouring day; align to the requested day
const shift = Math.round((noonMs - toMs(Jt)) / MS_DAY) * MS_DAY;
return { rise: toMs(Jt - w / 360) + shift, set: toMs(Jt + w / 360) + shift, noon: toMs(Jt) + shift };
}
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();
renderNwsCard();
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;
}
// Share of the day's 288 five-minute slots that have at least one reading, so it doesn't matter whether the
// station reports every 60, 100 or 120 s. Days saved before slots were tracked fall back to reading count.
function coverageFor(row, perDay) {
if (!row) return null;
if (row.slots != null) return Math.min(100, (row.slots / 288) * 100);
return 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 covVals = past.map((d) => coverageFor(d, perDay)).filter((v) => v != null);
const cov = covVals.length ? mean(covVals) : 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 (cov.length) {
const vals = cov.map((d) => +(coverageFor(d, perDay) ?? 0).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"]');
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 || right.querySelector('a[href="/dashboard/logout"]'))?.before(acct);
const nm = $('stationName');
if (nm && !nm.textContent) nm.textContent = me.username;
window.DASH_USER = me;
} catch {}
})();
// ── Official NWS observation vs your station ─────────────────────────────────
let NWS = null;
function renderNwsCard() {
const host = $('nwsCard');
if (!host) return;
const n = NWS, l = n?.latest, r = n?.resolved;
if (!n) { host.innerHTML = ''; return; }
if (!l) {
host.innerHTML = `<div class="ui-card"><h3>Official NWS observation</h3><p class="note">${n.lastError ? 'Could not reach the National Weather Service yet: ' + n.lastError : 'Waiting for the first observation from the National Weather Service…'}${r ? ` Station <b>${r.stationId}</b> for ZIP ${r.zip}.` : ''}</p></div>`;
return;
}
const f = (v, d = 0, u = '') => (num(v) == null ? '—' : v.toFixed(d) + u);
const ageMin = Math.round((Date.now() - l.ts) / 60000);
const mine = LATEST;
const diffs = [];
if (mine && num(mine.tempf) != null && num(l.tempf) != null && Math.abs(mine.dateutc - l.ts) < 90 * 60000) {
const d = mine.tempf - l.tempf;
diffs.push(Math.abs(d) < 0.5 ? 'Your station matches NWS on temperature' : `Your station reads <b>${Math.abs(d).toFixed(1)}°F ${d > 0 ? 'warmer' : 'cooler'}</b>`);
if (num(mine.humidity) != null && num(l.humidity) != null && Math.abs(mine.humidity - l.humidity) >= 3) diffs.push(`${Math.abs(mine.humidity - l.humidity).toFixed(0)}% ${mine.humidity > l.humidity ? 'more humid' : 'drier'}`);
}
const wind = num(l.windspeedmph) != null ? `${l.winddir != null ? cardinal(l.winddir) + ' ' : ''}${l.windspeedmph.toFixed(0)} mph${num(l.windgustmph) != null ? ' (g ' + l.windgustmph.toFixed(0) + ')' : ''}` : '—';
host.innerHTML = `<div class="ui-card nws-card"><h3>Official NWS observation <span class="nws-dot"></span></h3>
<p class="note" style="margin:-0.4rem 0 0.8rem"><b>${esc2(r?.stationId || l.station)}</b> ${esc2(r?.stationName || '')} · ${r?.distanceMi != null ? r.distanceMi + ' mi from ' : 'for '}ZIP ${esc2(r?.zip || '')}${r?.place ? ' (' + esc2(r.place) + ')' : ''}</p>
<div class="kv">
<div><b>${f(l.tempf, 1, '°F')}</b><span>Temperature</span></div>
<div><b>${f(l.dewPoint, 0, '°F')}</b><span>Dew point</span></div>
<div><b>${f(l.humidity, 0, '%')}</b><span>Humidity</span></div>
<div><b>${wind}</b><span>Wind</span></div>
<div><b>${f(l.baromrelin, 2, ' inHg')}</b><span>Pressure</span></div>
<div><b>${f(l.visibilityMi, 0, ' mi')}</b><span>Visibility</span></div>
<div><b>${esc2(l.text || '—')}</b><span>Conditions</span></div>
</div>
<p class="note" style="margin-top:0.8rem">Observed ${new Date(l.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} (${ageMin} min ago)${diffs.length ? ' · ' + diffs.join(' · ') : ''} · ${n.observations.toLocaleString()} observations logged${n.firstObservation ? ' since ' + prettyDay(dayStr(n.firstObservation)) : ''}${l.raw ? `<br><code class="nws-raw">${esc2(l.raw)}</code>` : ''}</p></div>`;
}
const esc2 = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ── Data refresh ─────────────────────────────────────────────────────────────
async function refreshData() {
try {
const [daily, recent, nws] = await Promise.all([
getJSON('/api/station/daily').catch(() => []),
getJSON('/api/station/history?range=2d').catch(() => []),
getJSON('/api/nws/latest').catch(() => null),
]);
DAILY = daily; RECENT = recent; NWS = nws;
LATEST = RECENT[RECENT.length - 1] || null;
const allBtn = document.querySelector('.range-btn[data-range="all"]');
if (allBtn && DAILY[0]) {
const first = new Date(dayMs(DAILY[0].d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
allBtn.title = `Everything recorded since ${first}`;
}
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();
})();
+462 -150
View File
@@ -2,22 +2,39 @@
<html lang="en">
<head>
<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="icon" type="image/png" sizes="192x192" href="/icons/v2/dashboard-icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/dashboard-apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/dashboard-apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/dashboard-apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/dashboard-apple-touch-icon-120.png">
<link rel="stylesheet" href="/pwa-safe.css">
<title>Station Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/dashboard-ui.css">
<script src="/alert-info.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649;
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7;
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
--ink: #0d1b2a; --ink-mid: #2b3f55; --ink-light: #5a7089;
--paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
--border: #ccd6e2; --accent: #c25a2a; --cool: #2a6b8a;
--ui: #2563eb; --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399;
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620;
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8;
--ink: #dce6f0; --ink-mid: #b7c6d6; --ink-light: #8aa2b8;
--paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29;
--border: #22405c; --accent: #fb923c; --cool: #38bdf8; --ui: #60a5fa;
}
}
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
@@ -321,6 +338,48 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
box-shadow: 0 0 0 2px rgba(255,195,0,0.12);
}
.record-best .record-label::after { content: ' ★'; color: #ffc300; }
/* ── iPhone / touch ── */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
html { overscroll-behavior-y: none; }
body { min-height: 100vh; min-height: 100dvh; }
.header { padding-top: calc(1rem + env(safe-area-inset-top)); padding-left: calc(1.5rem + env(safe-area-inset-left)); padding-right: calc(1.5rem + env(safe-area-inset-right)); }
.page { padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); padding-bottom: calc(4rem + env(safe-area-inset-bottom)); }
/* Let the page scroll when a finger starts on a chart; taps/long-press still show tooltips */
.chart-wrap canvas { touch-action: pan-y; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
button, .range-btn, .refresh-btn, .logout, summary { touch-action: manipulation; }
input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
@media (hover: none) {
.chart-card:hover, .today-bar:hover { transform: none; box-shadow: none; }
}
@media (pointer: coarse) {
.range-btn, .refresh-btn, .logout { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding-left: 0.9rem; padding-right: 0.9rem; }
.settings-panel summary { min-height: 48px; }
}
@media (max-width: 640px) {
.header { flex-wrap: wrap; gap: 0.6rem; padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); }
.header-right { width: 100%; flex-wrap: nowrap; overflow-x: auto; gap: 0.5rem; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 2px; }
.header-right::-webkit-scrollbar { display: none; }
.header-right > * { flex-shrink: 0; }
.range-bar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; }
.range-bar::-webkit-scrollbar { display: none; }
.range-bar > * { flex-shrink: 0; }
.today-bar { flex-wrap: wrap; row-gap: 0.4rem; }
.chart-card { padding: 0.8rem 0.8rem 1rem; }
.chart-wrap { height: 220px; }
.settings-body { padding: 0.9rem; }
.records-grid { grid-template-columns: repeat(2, 1fr); }
.record-val { font-size: 1.05rem; }
}
/* Records tables */
.rec-h { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin: 1.1rem 0 0.5rem; }
.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.rec-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
.rec-table th, .rec-table td { text-align: right; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.rec-table th:first-child, .rec-table td:first-child { text-align: left; }
.rec-table th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); font-weight: 600; }
.rec-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.1rem; }
.rec-actions a { display: inline-flex; align-items: center; min-height: 40px; padding: 0 0.9rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.8rem; font-weight: 600; background: var(--paper); }
</style>
</head>
<body>
@@ -332,17 +391,27 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div>
<div class="header-right">
<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="/dashboard/settings" class="logout">Settings</a>
<a href="/dashboard/logout" class="logout">Sign out</a>
</div>
</div>
<nav class="tabbar" id="tabbar" aria-label="Dashboard sections">
<button class="tab active" data-tab="now"><span class="tab-ico">◉</span><span>Now</span></button>
<button class="tab" data-tab="charts"><span class="tab-ico">〰</span><span>Charts</span></button>
<button class="tab" data-tab="rain"><span class="tab-ico">☂</span><span>Rain</span></button>
<button class="tab" data-tab="records"><span class="tab-ico">★</span><span>Records</span></button>
<button class="tab" data-tab="insights"><span class="tab-ico">✦</span><span>Insights</span></button>
</nav>
<div class="page">
<div class="offline-banner" id="offlineBanner"></div>
<div id="alertsSection" style="margin-bottom:0"></div>
<div class="tab-pane active" data-pane="now">
<div id="highlights" class="highlights"></div>
<!-- Hero: Current Conditions -->
<div class="section-card">
<div class="section-card-hdr">Current Conditions</div>
@@ -472,6 +541,10 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div>
</div>
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div><div id="nwsCard" style="grid-column:1/-1"></div></div>
</div><!-- /now -->
<div class="tab-pane" data-pane="charts">
<!-- Range selector -->
<div class="range-bar">
<span>History:</span>
@@ -484,8 +557,10 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
<button class="range-btn" data-range="1y">1y</button>
<button class="range-btn" data-range="2y">2y</button>
<button class="range-btn" data-range="3y">3y</button>
<button class="range-btn" data-range="all" title="Everything recorded so far">All</button>
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
<button class="range-btn nws-toggle" id="nwsBtn" title="Overlay official NWS observations for your reference ZIP">NWS overlay</button>
</div>
<div class="updated" id="updatedLine"></div>
@@ -494,30 +569,20 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
<div class="status" id="loadingMsg">Loading history…</div>
</div>
<!-- Records -->
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
<summary>All-Time Station Records</summary>
<div class="settings-body" id="recordsBody">
<div class="status">Loading records…</div>
</div>
</details>
</div><!-- /charts -->
<!-- Monthly Summary -->
<details class="settings-panel" id="monthlyPanel" style="margin-top:1rem">
<summary>Monthly Summary</summary>
<div class="settings-body" id="monthlyBody">
<div class="status">Loading…</div>
</div>
</details>
<div class="tab-pane" data-pane="rain" id="rainTab"><div class="status">Loading rain…</div></div>
<!-- Temperature Heatmap -->
<details class="settings-panel" id="heatmapPanel" style="margin-top:1rem">
<summary>Temperature Calendar</summary>
<div class="settings-body" id="heatmapBody">
<div class="status">Loading…</div>
</div>
</details>
<div class="tab-pane" data-pane="records">
<!-- Records (rendered by dashboard-records.js) -->
<div id="recordsRoot" class="rec2"><div class="status">Loading records…</div></div>
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
</div><!-- /records -->
<div class="tab-pane" data-pane="insights" id="insightsTab">
<div id="insightsBody"><div class="status">Loading insights…</div></div>
<!-- Alert History -->
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
<summary>Alert History</summary>
@@ -525,6 +590,7 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
<div class="status">Loading…</div>
</div>
</details>
</div><!-- /insights -->
</div>
@@ -535,12 +601,28 @@ const RANGE_LABELS = {
};
let currentRange = '24h';
// ── Phone-friendly charts ──
// Fewer points, no entry animation and a capped pixel ratio keep 11 charts cheap on an iPhone, and charts
// are only built while the Charts tab is actually on screen.
const NARROW_SCREEN = window.innerWidth < 700;
const MAX_PTS = NARROW_SCREEN ? 360 : 720;
let chartsDirty = true;
const chartsPaneActive = () => !!document.querySelector('.tab-pane[data-pane="charts"].active');
if (typeof Chart !== 'undefined') {
if (window.matchMedia('(pointer: coarse)').matches) Chart.defaults.animation = false;
Chart.defaults.devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
}
let charts = {};
// ── Formatting helpers ──────────────────────────────────────────────────────
function fmtTime(ts, range) {
const d = new Date(ts);
if (['7d','30d','90d','1y','2y','3y'].includes(range)) {
if (range === '2d') return d.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true });
if (['1y','2y','3y','all'].includes(range)) {
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
}
if (['7d','30d','90d'].includes(range)) {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
@@ -577,6 +659,10 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
zoom: {
zoom: { wheel: { enabled: true, modifierKey: 'ctrl' }, pinch: { enabled: true }, mode: 'x', onZoomComplete: ({ chart }) => markZoomed(chart) },
pan: { enabled: true, mode: 'x', threshold: 10, onPanComplete: ({ chart }) => markZoomed(chart) },
},
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.75)',
@@ -601,9 +687,23 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
const mergedOpts = deepMerge(defaults, opts);
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
ctx.ondblclick = () => { charts[canvasId].resetZoom(); markZoomed(charts[canvasId], false); };
return charts[canvasId];
}
function markZoomed(chart, on = true) {
const card = chart.canvas.closest('.chart-card');
if (!card) return;
let btn = card.querySelector('.chart-reset');
if (!btn) {
btn = document.createElement('button');
btn.className = 'chart-reset'; btn.textContent = 'Reset zoom';
btn.addEventListener('click', () => { chart.resetZoom(); markZoomed(chart, false); });
card.querySelector('.chart-title')?.appendChild(btn);
}
btn.style.display = on ? '' : 'none';
}
function deepMerge(a, b) {
const out = { ...a };
for (const k of Object.keys(b || {})) {
@@ -690,8 +790,96 @@ function drawWindRose(canvas, readings) {
})(t0);
}
// ── Pressure tendency (own range + change window) ────────────────────────────
let presTendRange = 'follow', presTendWin = 3;
try { presTendRange = localStorage.getItem('ptRange') || 'follow'; presTendWin = parseInt(localStorage.getItem('ptWin')) || 3; } catch {}
const presTendCache = {};
let presTendLast = { pts: null, range: null };
async function drawPresTend(pagePts, pageRange) {
const canvas = document.getElementById('cPresTend');
if (!canvas) return;
let pts = pagePts, range = pageRange;
if (presTendRange !== 'follow') {
range = presTendRange;
const hit = presTendCache[range];
if (hit && Date.now() - hit.at < 120000) pts = hit.data;
else {
try {
const bucket = ['7d', '30d', 'all'].includes(range) ? '&points=' + MAX_PTS : '';
const r = await fetch(`/api/station/history?range=${range}${bucket}`);
pts = r.ok ? await r.json() : [];
} catch { pts = []; }
presTendCache[range] = { at: Date.now(), data: pts };
}
if (document.getElementById('cPresTend') !== canvas) return; // charts were re-rendered meanwhile
}
if (pts.length > MAX_PTS * 1.25) { const step = Math.ceil(pts.length / MAX_PTS); pts = pts.filter((_, i) => i % step === 0); }
// Bucketed (long) ranges can't resolve changes shorter than one bucket: widen the window to fit
const bucketMs = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 0;
const effHours = Math.max(presTendWin, Math.ceil(bucketMs / 3600000));
const winMs = effHours * 3600000;
const c = chartColors();
const tend = pts.map(r => {
const t = r.dateutc - winMs;
if (!pts.length || t < pts[0].dateutc) return null;
let lo = 0, hi = pts.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < t) lo = m + 1; else hi = m; }
const base = pts[lo].baromrelin;
return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null;
});
const ds = barDataset(`${effHours}h change`, tend, c.cool);
ds.backgroundColor = tend.map(v => v == null ? 'transparent' : (v >= 0 ? c.cool + 'bb' : c.accent + 'bb'));
ds.borderWidth = 0;
makeChart('cPresTend', 'bar', [ds], pts.map(r => fmtTime(r.dateutc, range)), `inHg / ${effHours}h`);
const title = document.getElementById('ptTitle');
if (title) title.textContent = `Pressure Tendency · ${effHours}-hour change (inHg)${effHours !== presTendWin ? ' — widened to fit this range' : ''}`;
if (!tend.some(v => v != null) && title) title.textContent += ' — not enough data for this window';
}
function initPresTend(pagePts, pageRange) {
presTendLast = { pts: pagePts, range: pageRange };
const selRange = document.getElementById('ptRange'), selWin = document.getElementById('ptWin');
if (selRange) {
selRange.value = presTendRange;
selRange.addEventListener('change', () => { presTendRange = selRange.value; try { localStorage.setItem('ptRange', presTendRange); } catch {} drawPresTend(presTendLast.pts, presTendLast.range); });
}
if (selWin) {
selWin.value = String(presTendWin);
selWin.addEventListener('change', () => { presTendWin = parseInt(selWin.value); try { localStorage.setItem('ptWin', presTendWin); } catch {} drawPresTend(presTendLast.pts, presTendLast.range); });
}
drawPresTend(pagePts, pageRange);
}
// ── NWS overlay (official observations for the reference ZIP) ─────────────────
let nwsOverlay = true;
try { nwsOverlay = localStorage.getItem('nwsOverlay') !== '0'; } catch {}
const NWS_COLOR = '#a78bfa';
// Map sparse NWS observations onto the chart's points (averaging when several land on one point)
function nwsAlign(pts, nws, key) {
if (!nws || !nws.length || !pts.length) return null;
const sum = new Array(pts.length).fill(0), cnt = new Array(pts.length).fill(0);
const span = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 3600000;
const tol = Math.max(span / 2, 35 * 60000);
for (const o of nws) {
const v = o[key]; if (v == null) continue;
let lo = 0, hi = pts.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < o.ts) lo = m + 1; else hi = m; }
let idx = lo;
if (idx > 0 && Math.abs(pts[idx - 1].dateutc - o.ts) < Math.abs(pts[idx].dateutc - o.ts)) idx--;
if (Math.abs(pts[idx].dateutc - o.ts) > tol) continue;
sum[idx] += v; cnt[idx]++;
}
const out = sum.map((t, i) => (cnt[i] ? +(t / cnt[i]).toFixed(2) : null));
return out.some(v => v != null) ? out : null;
}
function nwsDataset(label, data, dashed) {
return { label, data, borderColor: NWS_COLOR, backgroundColor: NWS_COLOR, borderWidth: 1.6, borderDash: dashed ? [4, 3] : [], pointRadius: 3, pointHoverRadius: 5, tension: 0, spanGaps: true, fill: false };
}
// ── Render charts ────────────────────────────────────────────────────────────
function renderCharts(readings, range, yoy = null) {
function renderCharts(readings, range, yoy = null, nws = null) {
const area = document.getElementById('chartsArea');
if (!readings.length) {
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
@@ -703,8 +891,8 @@ function renderCharts(readings, range, yoy = null) {
// downsample for 30d to keep chart manageable
let pts = readings;
if (readings.length > 720) {
const step = Math.ceil(readings.length / 720);
if (readings.length > MAX_PTS) {
const step = Math.ceil(readings.length / MAX_PTS);
pts = readings.filter((_, i) => i % step === 0);
}
const lbls = pts.map(r => fmtTime(r.dateutc, range));
@@ -745,6 +933,19 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-wrap"><canvas id="cPres"></canvas></div>
</div>
<div class="chart-card wide" id="cardPresTend">
<div class="chart-title"><span id="ptTitle">Pressure Tendency (inHg)</span>
<span class="chart-ctl">
<label>Range <select id="ptRange" aria-label="Pressure tendency range">
<option value="follow">Same as page</option><option value="6h">6 hours</option><option value="24h">24 hours</option>
<option value="2d">2 days</option><option value="7d">7 days</option><option value="30d">30 days</option><option value="all">All time</option></select></label>
<label>Change over <select id="ptWin" aria-label="Pressure change window">
<option value="1">1 hour</option><option value="3">3 hours</option><option value="6">6 hours</option>
<option value="12">12 hours</option><option value="24">24 hours</option></select></label>
</span></div>
<div class="chart-wrap" style="height:140px"><canvas id="cPresTend"></canvas></div>
</div>
<!-- Rain -->
<div class="chart-card">
<div class="chart-title">Rain Rate (in/hr)</div>
@@ -768,35 +969,44 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
</div>
${readings.some(r => r.tempinf != null) ? `<div class="chart-card wide">
<div class="chart-title">Indoor vs Outdoor</div>
<div class="chart-wrap"><canvas id="cIndoor"></canvas></div>
</div>` : ''}
</div>
`;
const tempDatasets = [
lineDataset('Temp °F', field(pts, 'tempf'), c.accent),
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true),
];
const tempDatasets = [];
if (pts.some(r => r.tempMax != null)) {
// Bucketed long ranges: show the true min/max envelope behind the average
tempDatasets.push({ label: 'Max', data: field(pts, 'tempMax'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: '+1', tension: 0.3 });
tempDatasets.push({ label: 'Min', data: field(pts, 'tempMin'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: false, tension: 0.3 });
}
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
const nwsLab = nws && nws.length ? 'NWS ' + (nws[nws.length - 1].station || '') : 'NWS';
const addNws = (sets, key, suffix, dashed) => { const a = nwsAlign(pts, nws, key); if (a) sets.push(nwsDataset(`${nwsLab} ${suffix}`, a, dashed)); };
addNws(tempDatasets, 'tempf', 'temp');
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 > MAX_PTS ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / MAX_PTS) === 0) : yoy.prior;
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
}
makeChart('cTemp', 'line', tempDatasets, lbls, '°F');
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
makeChart('cHum', 'line',
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)],
lbls, null);
const humSets = [lineDataset('Humidity %', field(pts, 'humidity'), c.cool), lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)];
addNws(humSets, 'humidity', 'humidity'); addNws(humSets, 'dewPoint', 'dew pt', true);
makeChart('cHum', 'line', humSets, lbls, null);
makeChart('cWind', 'line',
[lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool),
lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)],
lbls, 'mph');
const windSets = [lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool), lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)];
addNws(windSets, 'windspeedmph', 'wind'); addNws(windSets, 'windgustmph', 'gust', true);
makeChart('cWind', 'line', windSets, lbls, 'mph');
const hasPres = pts.some(r => r.baromrelin != null);
if (hasPres) {
makeChart('cPres', 'line',
[lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent),
lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)],
lbls, 'inHg');
const presSets = [lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent), lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)];
addNws(presSets, 'baromrelin', 'pressure');
makeChart('cPres', 'line', presSets, lbls, 'inHg');
}
makeChart('cRainRate', 'bar',
@@ -811,13 +1021,25 @@ function renderCharts(readings, range, yoy = null) {
[lineDataset('UV Index', field(pts, 'uv'), c.accent)],
lbls, null, { scales: { y: { min: 0 } } });
makeChart('cSolar', 'line',
[lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)],
lbls, 'W/m²', { scales: { y: { min: 0 } } });
const solarSets = [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)];
if (typeof clearSkyGHI === 'function' && ['1h', '6h', '24h', '2d', '7d'].includes(range) && window.STATION_LOC) {
const cs = pts.map(r => clearSkyGHI(r.dateutc));
if (cs.some(v => v != null)) solarSets.push(lineDataset('Clear-sky potential', cs, c.cool, true));
}
makeChart('cSolar', 'line', solarSets, lbls, 'W/m²', { scales: { y: { min: 0 } } });
// Pressure tendency: change vs N hours earlier (what forecasters watch); has its own range + window
if (hasPres) initPresTend(pts, range);
// Indoor vs outdoor
if (pts.some(r => r.tempinf != null)) {
makeChart('cIndoor', 'line',
[lineDataset('Outdoor °F', field(pts, 'tempf'), c.accent),
lineDataset('Indoor °F', field(pts, 'tempinf'), c.cool),
lineDataset('Indoor humidity %', field(pts, 'humidityin'), c.cool, true)],
lbls, null);
}
// Today bar + sparklines use full readings (not downsampled pts)
updateTodayBar(readings);
updateSparklines(readings);
// Wind rose
const roseCanvas = document.getElementById('windRoseCanvas');
@@ -941,18 +1163,22 @@ async function updateLatest() {
// ── Load history ─────────────────────────────────────────────────────────────
async function loadHistory(range) {
if (!chartsPaneActive()) { chartsDirty = true; return; } // build charts when the Charts tab is opened
chartsDirty = false;
const area = document.getElementById('chartsArea');
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
try {
const r = await fetch(`/api/station/history?range=${range}`);
const pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=' + MAX_PTS : '';
const nwsP = nwsOverlay ? fetch(`/api/nws/history?range=${range}`).then(x => (x.ok ? x.json() : [])).catch(() => []) : Promise.resolve([]);
const r = await fetch(`/api/station/history?range=${range}${pts}`);
if (r.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error('Failed');
const data = await r.json();
renderCharts(data, range, yoyEnabled ? yoyData : null);
renderCharts(data, range, yoyEnabled ? yoyData : null, await nwsP);
const line = document.getElementById('updatedLine');
if (line) {
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
line.textContent = `${data.length} readings · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
line.textContent = `${data.length}${data.some(d => d.n) ? ' time buckets' : ' readings'} · pinch or Ctrl+scroll to zoom · double-click to reset · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
}
} catch (e) {
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
@@ -1036,6 +1262,66 @@ function fmtDate(ts) {
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderRecordPeriods(d, ri) {
const dayDate = (day) => day ? Date.parse(day + 'T12:00:00Z') : null;
const n = d.notable || {};
const nv = (x, f) => x ? f(x.value) : '—';
let html = '<div class="rec-h">Notable days</div><div class="records-grid">' + [
['Hottest Day', nv(n.hottestDay, v => v.toFixed(1) + '°F'), n.hottestDay],
['Coldest Night', nv(n.coldestDay, v => v.toFixed(1) + '°F'), n.coldestDay],
['Coolest Daytime High', nv(n.coolestHigh, v => v.toFixed(1) + '°F'), n.coolestHigh],
['Warmest Night', nv(n.warmestLow, v => v.toFixed(1) + '°F'), n.warmestLow],
['Windiest Day', nv(n.windiestDay, v => v.toFixed(1) + ' mph'), n.windiestDay],
['Wettest Day', nv(n.wettestDay, v => v.toFixed(2) + '"'), n.wettestDay],
].map(([l, v, o]) => ri(l, v, o ? dayDate(o.day) : null)).join('') + '</div>';
// Months & years: the period itself (e.g. "October 2026") is the headline of each card
const now = new Date();
const curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'), curYear = String(now.getFullYear());
const longMonth = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
const rp = (label, rec, fmtVal, isMonth, waitMsg) => {
if (!rec) return `<div class="record-item"><div class="record-label">${label}</div><div class="record-val">—</div><div class="record-period">${waitMsg || 'Not enough data yet'}</div></div>`;
const current = rec.key === (isMonth ? curMonth : curYear);
return `<div class="record-item${current ? ' record-best' : ''}"><div class="record-label">${label}</div><div class="record-val">${fmtVal(rec.value)}</div>
<div class="record-period">${isMonth ? longMonth(rec.key) : rec.key}</div><div class="record-date">${rec.days} day${rec.days === 1 ? '' : 's'} recorded${rec.partial ? ' · partial' : ''}</div></div>`;
};
const P = JSON.parse(JSON.stringify(d.periods || { months: {}, years: {} }));
// An extreme and its opposite landing on the same period just means there's nothing to compare yet
const same = (a, b) => a && b && a.key === b.key;
const waitM = 'Needs another full month', waitY = 'Needs another year of data';
let wM = {}, wY = {};
if (same(P.months.hottest, P.months.coldest)) { P.months.coldest = null; wM.coldest = waitM; }
if (same(P.months.wettest, P.months.driest)) { P.months.driest = null; wM.driest = waitM; }
if (same(P.years.hottest, P.years.coldest)) { P.years.coldest = null; wY.coldest = waitY; }
const deg = (v) => v.toFixed(1) + '°F', inch = (v) => v.toFixed(2) + '"', mph = (v) => v.toFixed(0) + ' mph';
html += '<div class="rec-h">Months <span class="rec-sub">(avg temp = mean of all readings; partial months are flagged)</span></div><div class="records-grid">'
+ rp('Wettest Month', P.months.wettest, inch, true) + rp('Driest Month', P.months.driest, inch, true, wM.driest)
+ rp('Hottest Month', P.months.hottest, deg, true) + rp('Coldest Month', P.months.coldest, deg, true, wM.coldest)
+ rp('Windiest Month', P.months.windiest, mph, true) + '</div>';
html += '<div class="rec-h">Years</div><div class="records-grid">'
+ rp('Wettest Year', P.years.wettest, inch, false) + rp('Hottest Year', P.years.hottest, deg, false)
+ rp('Coldest Year', P.years.coldest, deg, false, wY.coldest) + rp('Windiest Year', P.years.windiest, mph, false) + '</div>';
// Records for each calendar month, whichever year they happened in
if (d.calendarMonths && d.calendarMonths.some(c => c.years)) {
const mn = (i) => new Date(Date.UTC(2001, i, 15)).toLocaleDateString('en-US', { month: 'long', timeZone: 'UTC' });
const cell = (x, fmtV) => x ? `<b>${fmtV(x.value)}</b><span class="rec-yr">${x.day ? new Date(x.day + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : ''}</span>` : '<span class="rec-yr">—</span>';
const rows = d.calendarMonths.map((c, i) => `<tr${c.years ? '' : ' class="empty"'}><td><b>${mn(i)}</b><span class="rec-yr">${c.years ? c.years + ' year' + (c.years > 1 ? 's' : '') : 'no data'}</span></td>
<td>${cell(c.hi, deg)}</td><td>${cell(c.lo, deg)}</td><td>${cell(c.wettestDay, inch)}</td>
<td>${c.wettestTotal ? `<b>${inch(c.wettestTotal.value)}</b><span class="rec-yr">${longMonth(c.wettestTotal.month)}</span>` : '<span class="rec-yr">—</span>'}</td><td>${cell(c.gust, mph)}</td></tr>`).join('');
html += `<div class="rec-h">Records by calendar month <span class="rec-sub">(any year)</span></div><div class="rec-scroll"><table class="rec-table rec-cal"><tr><th>Month</th><th>Record high</th><th>Record low</th><th>Wettest day</th><th>Wettest month</th><th>Peak gust</th></tr>${rows}</table></div>`;
}
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
const row = (label, a) => `<tr><td>${label}</td><td>${a.hi != null ? a.hi.toFixed(1) : '—'}</td><td>${a.lo != null ? a.lo.toFixed(1) : '—'}</td><td>${a.gust != null ? a.gust.toFixed(0) : '—'}</td><td>${a.rain.toFixed(2)}</td><td>${a.days}</td></tr>`;
const head = '<tr><th></th><th>High °F</th><th>Low °F</th><th>Gust</th><th>Rain "</th><th>Days</th></tr>';
if (d.years && d.years.length) {
html += `<div class="rec-h">By year</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.years].reverse().map(y => row(y.year, y)).join('')}</table></div>`;
}
if (d.months && d.months.length) {
html += `<div class="rec-h">By month</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.months].reverse().map(m => row(mname(m.month), m)).join('')}</table></div>`;
}
return html;
}
async function loadRecords() {
const panel = document.getElementById('recordsPanel');
if (!panel) return;
@@ -1048,7 +1334,7 @@ async function loadRecords() {
if (!r.ok) { body.innerHTML = '<div class="status">No records yet.</div>'; return; }
const d = await r.json();
if (!d) { body.innerHTML = '<div class="status">No data recorded yet.</div>'; return; }
const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings since ${fmtDate(d.firstReading)}` : '';
const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings over ${d.daysRecorded} days since ${fmtDate(d.firstReading)}` : '';
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
const ri = (label, val, date) => {
const recent = date && new Date(date).getTime() > cutoff;
@@ -1068,6 +1354,11 @@ async function loadRecords() {
${ri('Lowest Pressure', d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—', d.lowPressureDate)}
${ri('Highest Humidity', d.highHumidity != null ? d.highHumidity + '%' : '—', d.highHumidityDate)}
${ri('Lowest Humidity', d.lowHumidity != null ? d.lowHumidity + '%' : '—', d.lowHumidityDate)}
</div>
${renderRecordPeriods(d, ri)}
<div class="rec-actions">
<a href="/api/station/backup.jsonl" download>⬇ Download all readings</a>
<a href="/api/station/records-backup.json" download>⬇ Download records</a>
</div>`;
} catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; }
}, { once: false });
@@ -1077,6 +1368,17 @@ async function loadRecords() {
let yoyEnabled = false;
let yoyData = null;
const nwsBtn = document.getElementById('nwsBtn');
if (nwsBtn) {
nwsBtn.classList.toggle('active', nwsOverlay);
nwsBtn.addEventListener('click', () => {
nwsOverlay = !nwsOverlay;
try { localStorage.setItem('nwsOverlay', nwsOverlay ? '1' : '0'); } catch {}
nwsBtn.classList.toggle('active', nwsOverlay);
loadHistory(currentRange);
});
}
const yoyBtn = document.getElementById('yoyBtn');
yoyBtn.addEventListener('click', async () => {
yoyEnabled = !yoyEnabled;
@@ -1167,64 +1469,6 @@ if (monthlyPanel) {
}, { once: false });
}
// ── Temperature heatmap ───────────────────────────────────────────────────────
const heatmapPanel = document.getElementById('heatmapPanel');
if (heatmapPanel) {
heatmapPanel.addEventListener('toggle', async () => {
if (!heatmapPanel.open) return;
const body = document.getElementById('heatmapBody');
if (body.querySelector('.heatmap-wrap')) return;
try {
const r = await fetch('/api/station/history?range=1y');
if (!r.ok) throw new Error();
const readings = await r.json();
if (!readings.length) { body.innerHTML = '<div class="status">No data yet.</div>'; return; }
// Daily high temp
const byDay = {};
for (const rd of readings) {
if (rd.tempf == null || !rd.dateutc) continue;
const day = new Date(rd.dateutc).toISOString().slice(0, 10);
if (byDay[day] == null || rd.tempf > byDay[day]) byDay[day] = rd.tempf;
}
// Color scale: cold = cool blue, hot = accent orange
function heatColor(t) {
if (t == null) return 'var(--border)';
const norm = Math.max(0, Math.min(1, (t - 20) / 90));
const r = Math.round(42 + norm * (224 - 42));
const g = Math.round(107 + norm * (120 - 107));
const b = Math.round(138 + norm * (42 - 138));
return `rgb(${r},${g},${b})`;
}
// Build grid: iterate days from 1yr ago to today
const today = new Date(); today.setHours(23, 59, 59, 0);
const start = new Date(today); start.setFullYear(start.getFullYear() - 1); start.setDate(start.getDate() + 1);
// Align to Sunday
const startDow = start.getDay(); // 0=Sun
const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null); // padding
const cur = new Date(start);
while (cur <= today) {
const key = cur.toISOString().slice(0, 10);
cells.push({ date: key, temp: byDay[key] ?? null });
cur.setDate(cur.getDate() + 1);
}
const cellHtml = cells.map(c => {
if (!c) return `<div class="heatmap-cell" style="background:transparent"></div>`;
const t = c.temp;
const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date;
return `<div class="heatmap-cell" style="background:${heatColor(t)}" title="${label}"></div>`;
}).join('');
body.innerHTML = `
<div class="heatmap-wrap"><div class="heatmap">${cellHtml}</div></div>
<div class="heatmap-legend">
<span>Cooler</span>
${[20,35,50,65,80,95,110].map(t => `<div class="heatmap-legend-swatch" style="background:${heatColor(t)}"></div>`).join('')}
<span>Hotter</span>
</div>`;
} catch { document.getElementById('heatmapBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
}, { once: false });
}
// ── Tile color coding ─────────────────────────────────────────────────────────
function tempColorClass(t) {
if (t == null) return '';
@@ -1406,6 +1650,91 @@ const TAG_LABELS = {
let alertHistoryDays = 30;
// Plain-language helpers are shared with the weather app (alert-info.js)
const { meaning: alertMeaning, firstSentence: ahFirstSentence, area: ahArea, when: ahWhen } = window.AlertInfo;
const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false };
const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
function ahRow(it, i) {
const isNws = it.type === 'nws';
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
const open = ahState.open.has(i);
let what, meta = [];
if (isNws) {
what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || '';
if (it.areaDesc) {
const parts = String(it.areaDesc).split(';').map(x => x.trim()).filter(Boolean);
meta.push(`📍 ${esc(parts.length > 4 ? parts.slice(0, 4).join(', ') + ' +' + (parts.length - 4) + ' more' : ahArea(it.areaDesc))}`);
}
else if (ahState.loading.has(it.alertId)) meta.push('📍 looking up area…');
if (it.severity && it.severity !== 'Unknown') meta.push(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`);
if (it.effective || it.expires) meta.push(`🕑 ${esc(ahWhen(it.effective))}${it.expires ? ' → ' + esc(ahWhen(it.expires)) : ''}`);
} else {
what = it.body || '';
}
let full = '';
if (open) {
if (isNws) {
const meaning = alertMeaning(it.event);
const loading = ahState.loading.has(it.alertId);
full = `<div class="ah-full">
${meaning ? `<p class="ah-meaning"><b>What this means:</b> ${esc(meaning)}</p>` : ''}
${it.headline ? `<p><b>Headline:</b> ${esc(it.headline)}</p>` : ''}
${it.areaDesc ? `<p><b>Area:</b> ${esc(ahArea(it.areaDesc))}</p>` : ''}
${it.description ? `<p class="ah-desc"><b>Details:</b>\n${esc(it.description)}</p>` : loading ? '<p class="ah-note">Fetching the full text from the National Weather Service…</p>' : ahState.gone.has(it.alertId) ? '<p class="ah-note">The National Weather Service no longer provides the full text for this older alert.</p>' : ''}
${it.instruction ? `<p class="ah-desc"><b>What to do:</b>\n${esc(it.instruction)}</p>` : ''}
<p class="ah-note">${[it.severity && 'Severity: ' + it.severity, it.urgency && 'Urgency: ' + it.urgency, it.certainty && 'Certainty: ' + it.certainty, it.senderName && 'Issued by ' + it.senderName].filter(Boolean).map(esc).join(' · ')}</p>
</div>`;
} else full = `<div class="ah-full"><p>${esc(it.body || '')}</p></div>`;
}
return `<tr class="ah-row${open ? ' open' : ''}" data-i="${i}" title="Click for the full alert">
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
<td>${badge}</td>
<td style="font-weight:600">${esc(label)}</td>
<td><div class="ah-what">${esc(what)}</div>${meta.length ? `<div class="ah-meta">${meta.join(' · ')}</div>` : ''}${full}</td>
</tr>`;
}
function ahRender() {
const tbody = document.getElementById('ahBody');
if (!tbody) return;
tbody.innerHTML = ahState.items.map(ahRow).join('');
tbody.querySelectorAll('.ah-full').forEach((f) => f.addEventListener('click', (e) => e.stopPropagation())); // allow selecting/copying text
tbody.querySelectorAll('.ah-row').forEach((row) => row.addEventListener('click', () => {
const i = +row.dataset.i, it = ahState.items[i];
if (ahState.open.has(i)) ahState.open.delete(i); else { ahState.open.add(i); ahLookup(it, true); }
ahRender();
}));
}
// Fetch missing text/area for older NWS entries (cached on the server), a few at a time
function ahApply(id, detail) {
for (const it of ahState.items) if (it.alertId === id) for (const [k, v] of Object.entries(detail || {})) if (v != null && it[k] == null) it[k] = v;
}
function ahLookup(it, urgent) {
if (!it || it.type !== 'nws' || !it.alertId || (it.description && it.areaDesc) || ahState.gone.has(it.alertId) || ahState.loading.has(it.alertId)) return;
if (urgent) ahState.queue.unshift(it.alertId); else ahState.queue.push(it.alertId);
ahState.loading.add(it.alertId);
ahPump();
}
async function ahPump() {
if (ahState.running) return;
ahState.running = true;
while (ahState.queue.length) {
const id = ahState.queue.shift();
try {
const r = await fetch('/api/dashboard/alert-detail?id=' + encodeURIComponent(id));
if (r.ok) ahApply(id, await r.json()); else ahState.gone.add(id);
} catch { ahState.gone.add(id); }
ahState.loading.delete(id);
ahRender();
await new Promise((res) => setTimeout(res, 120));
}
ahState.running = false;
}
async function loadAlertHistory(days) {
alertHistoryDays = days;
const body = document.getElementById('alertHistoryBody');
@@ -1414,7 +1743,7 @@ async function loadAlertHistory(days) {
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
if (!r.ok) throw new Error();
const items = await r.json();
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
ahState.items = items; ahState.open = new Set(); ahState.queue = []; ahState.loading = new Set(); ahState.gone = new Set();
body.innerHTML = `
<div class="alert-history-bar">
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
@@ -1424,35 +1753,16 @@ async function loadAlertHistory(days) {
${items.length === 0
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
<tbody>${items.map(it => {
const isNws = it.type === 'nws';
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
const detail = isNws ? (it.headline || '') : (it.body || '');
const full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail;
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
<td>${badge}</td>
<td style="font-weight:600">${label}</td>
<td><div class="ah-body">${esc(detail)}</div></td>
</tr>`;
}).join('')}</tbody>
</table></div>`
}`;
body.querySelectorAll('.ah-row').forEach(row => {
row.addEventListener('click', () => {
const el = row.querySelector('.ah-body');
const open = row.classList.toggle('open');
el.textContent = open ? row.dataset.full : el.dataset.short;
});
const el = row.querySelector('.ah-body');
el.dataset.short = el.textContent;
});
body.querySelectorAll('[data-ahdays]').forEach(btn => {
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
});
<thead><tr><th>Time</th><th>Type</th><th>Alert</th><th>What it's for · where · when</th></tr></thead>
<tbody id="ahBody"></tbody>
</table></div>`}`;
body.querySelectorAll('[data-ahdays]').forEach(btn => btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays))));
if (items.length) {
ahRender();
// Look up missing details in the background, newest first
const seen = new Set();
for (const it of items) if (it.type === 'nws' && it.alertId && !(it.description && it.areaDesc) && !seen.has(it.alertId)) { seen.add(it.alertId); ahLookup(it, false); }
}
} catch {
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
}
@@ -1490,5 +1800,7 @@ setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000);
</script>
<script src="/dashboard-ui.js"></script>
<script src="/dashboard-records.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{
"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/v2/dashboard-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/v2/dashboard-apple-touch-icon-180.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/v2/dashboard-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/v2/dashboard-icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+21 -11
View File
@@ -1,12 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Solid background (fills corners for maskable safe zone) -->
<rect width="512" height="512" fill="#1a1a1a"/>
<!-- Sun -->
<circle cx="205" cy="205" r="70" fill="#e07848"/>
<!-- Cloud bumps (front, overlapping sun) -->
<circle cx="215" cy="325" r="72" fill="#ece7dc"/>
<circle cx="305" cy="297" r="88" fill="#ece7dc"/>
<circle cx="390" cy="328" r="62" fill="#ece7dc"/>
<!-- Cloud base (flat bottom) -->
<rect x="147" y="335" width="305" height="68" fill="#ece7dc"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#0a1a40"/></linearGradient>
<radialGradient id="glow" cx="0.18" cy="0.1" r="0.7"><stop offset="0" stop-color="#fff" stop-opacity="0.28"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
<radialGradient id="sun" cx="0.4" cy="0.4" r="0.7"><stop offset="0" stop-color="#ffe9a8"/><stop offset="1" stop-color="#f97316"/></radialGradient>
<radialGradient id="sunglow"><stop offset="0" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="1" stop-color="#fbbf24" stop-opacity="0"/></radialGradient>
<linearGradient id="cloud" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c9dcf5"/></linearGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="160%"><feGaussianBlur stdDeviation="22"/></filter>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<rect width="1024" height="1024" fill="url(#glow)"/>
<circle cx="400" cy="430" r="230" fill="url(#sunglow)"/>
<circle cx="400" cy="430" r="132" fill="url(#sun)"/>
<g filter="url(#soft)" opacity="0.35" transform="translate(0 26)" fill="#06122e">
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
<rect x="300" y="560" width="432" height="132" rx="66"/>
</g>
<g fill="url(#cloud)">
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
<rect x="300" y="560" width="432" height="132" rx="66"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#0a1a40"/></linearGradient>
<radialGradient id="glow" cx="0.18" cy="0.1" r="0.7"><stop offset="0" stop-color="#fff" stop-opacity="0.28"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
<radialGradient id="sun" cx="0.4" cy="0.4" r="0.7"><stop offset="0" stop-color="#ffe9a8"/><stop offset="1" stop-color="#f97316"/></radialGradient>
<radialGradient id="sunglow"><stop offset="0" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="1" stop-color="#fbbf24" stop-opacity="0"/></radialGradient>
<linearGradient id="cloud" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c9dcf5"/></linearGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="160%"><feGaussianBlur stdDeviation="22"/></filter>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<rect width="1024" height="1024" fill="url(#glow)"/>
<circle cx="400" cy="430" r="230" fill="url(#sunglow)"/>
<circle cx="400" cy="430" r="132" fill="url(#sun)"/>
<g filter="url(#soft)" opacity="0.35" transform="translate(0 26)" fill="#06122e">
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
<rect x="300" y="560" width="432" height="132" rx="66"/>
</g>
<g fill="url(#cloud)">
<circle cx="392" cy="572" r="96"/><circle cx="522" cy="522" r="126"/><circle cx="648" cy="582" r="86"/>
<rect x="300" y="560" width="432" height="132" rx="66"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+200 -252
View File
@@ -3,48 +3,65 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="stylesheet" href="/pwa-safe.css">
<title>NeCloud Weather</title>
<link rel="manifest" href="/manifest.json">
<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="#191510" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/svg+xml" href="/icons/v2/icon.svg">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/v2/icon-192.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/v2/apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/v2/apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/v2/apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/v2/apple-touch-icon-120.png">
<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="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NeCloud Weather">
<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="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="station-live.css">
<link rel="stylesheet" href="app-ui.css">
</head>
<body>
<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 class="app">
<header class="masthead">
<h1>NeCloud Weather</h1>
<nav class="tabs">
<button class="tab active" data-tab="conditions">Conditions</button>
<header class="topbar">
<a class="brand" href="/" aria-label="NeCloud Weather home"><span class="brand-mark">☁︎</span><span class="brand-name">NeCloud</span></a>
<nav class="tabs" aria-label="Sections">
<button class="tab active" data-tab="conditions">Now</button>
<button class="tab" data-tab="station" title="Live readings from a personal weather station">Station</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="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>
<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>
<!-- Loading -->
<div id="loading" class="loading hidden">
<div class="loading-container">
<div class="loading-icon">🌤️</div>
<div class="loading-text">Fetching weather data…</div>
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
</div>
<!-- Install-as-app prompt (phones, not yet installed, dismissible) -->
<div id="install-banner" class="install-banner hidden" role="region" aria-label="Install the app">
<span class="ib-ico" aria-hidden="true">📲</span>
<div class="ib-text"><b>Install NeCloud Weather</b><span>Add it to your Home Screen for a full-screen app.</span></div>
<button type="button" class="ib-btn" id="ib-how">How</button>
<button type="button" class="ib-x" id="ib-close" aria-label="Dismiss">✕</button>
</div>
<!-- Loading: skeleton of the Now tab -->
<div id="loading" class="loading hidden" aria-busy="true" aria-label="Loading weather">
<div class="sk sk-hero"></div>
<div class="sk sk-line"></div>
<div class="sk sk-card"></div>
<div class="sk sk-card tall"></div>
<div class="sk-rows"><div class="sk sk-row"></div><div class="sk sk-row"></div><div class="sk sk-row"></div></div>
</div>
<!-- ── TAB: CONDITIONS ── -->
@@ -62,17 +79,19 @@
</div>
</div>
<!-- ── HERO ── -->
<div class="hero">
<!-- ── HERO (dynamic sky) ── -->
<div class="hero" id="hero">
<canvas id="sky-fx" class="sky-fx" aria-hidden="true"></canvas>
<div class="hero-left">
<div id="cur-icon" class="hero-icon"></div>
<div id="cur-desc" class="hero-desc"></div>
<div id="cur-precip" class="hero-sub"></div>
<div id="station-badge" class="station-badge hidden">📡 <span id="station-badge-name">My Station</span></div>
<div id="station-badge" class="station-badge hidden" title="These readings come from a personal weather station on site, not the forecast model. See the Station tab.">📡 <span id="station-badge-name">My Station</span></div>
</div>
<div class="hero-center">
<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="hero-context hidden" id="hero-context"></div>
<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>
</div>
@@ -93,107 +112,40 @@
</div>
</div>
<!-- ── SUN & MOON TIMELINE ── -->
<div class="sun-section">
<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>
<!-- ── ONE-LINE SUMMARY ── -->
<div class="summary-line" id="summary-line"></div>
<!-- ── NEXT 2 HOURS + WHAT TO BRING ── -->
<section class="ui-card nowcast" id="nowcast">
<div class="section-head">
<span class="section-label">Next 2 hours</span>
<span class="section-note" id="nowcast-note"></span>
</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 class="nowcast-bars" id="nowcast-bars"></div>
<div class="bring-row">
<span class="bring-label">Bring</span>
<div id="recommendations" class="recs-grid"></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>
<!-- ── WIND & ATMOSPHERE (collapsed by default on mobile) ── -->
<details class="details-drawer wind-drawer" id="wind-drawer">
<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">
<!-- ── RADAR ── -->
<div class="section-head sticky-head">
<span class="section-label">Radar</span>
<span class="section-note">
<button class="link-btn" id="open-map-tab">Open map →</button>
@@ -210,26 +162,71 @@
</div>
</div>
<!-- Hourly -->
<div class="section-head" style="margin-top:1.5rem">
<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">
<!-- ── 7-DAY ── -->
<div class="section-head sticky-head">
<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>
</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">
<summary class="drawer-summary">
Forecaster's Discussion
@@ -242,68 +239,6 @@
</div>
</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">
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
@@ -313,25 +248,42 @@
</div>
<!-- ── 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>
<!-- ── TAB: MAP (full-bleed map, floating glass controls) ── -->
<div id="tab-unified-map" class="tab-panel hidden">
<div class="unified-map-wrap">
<!-- Map Controls -->
<div class="map-controls-header">
<div class="map-controls-row">
<label for="base-map-select" class="control-label">Base Map:</label>
<select id="base-map-select" class="map-select">
<div class="unified-map-shell">
<div id="unified-map" class="unified-map-container"></div>
<div class="map-overlay-readouts" id="map-overlay-readouts">
<span class="map-readout-chip" id="overlay-temp">Temp --</span>
<span class="map-readout-chip" id="overlay-aqi">AQI --</span>
<span class="map-readout-chip" id="overlay-sunmoon">Sun -- • Moon --</span>
</div>
<div class="map-hud" role="toolbar" aria-label="Map controls">
<button id="layer-menu-toggle" class="hud-btn" type="button" aria-expanded="false" aria-label="Map layers" title="Layers">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M12 3 3 8l9 5 9-5-9-5Z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round" stroke-linecap="round"/></svg>
</button>
<button id="map-recenter-btn" class="hud-btn" type="button" aria-label="My location" title="My location">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M21 3 3 10.5l7 2.5 2.5 7L21 3Z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg>
</button>
<button id="map-fullscreen-btn" class="hud-btn radar-fs-btn" type="button" aria-label="Full screen" title="Full screen">⛶</button>
</div>
<div id="layer-menu-panel" class="map-layer-menu-panel glass hidden" role="dialog" aria-label="Map layers">
<div class="lm-head"><b>Map</b><button type="button" id="layer-menu-close" class="lm-close" aria-label="Close layers">✕</button></div>
<label class="lm-style" for="base-map-select"><span>Style</span> <select id="base-map-select" class="map-select">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="satellite">Satellite</option>
</select>
<button id="map-fullscreen-btn" class="radar-btn radar-fs-btn" title="Fullscreen">⛶</button>
</div>
<div class="map-layer-menu">
<button id="layer-menu-toggle" class="map-layer-menu-toggle" type="button" aria-expanded="false">Show Layer Menu</button>
<div id="layer-menu-panel" class="map-layer-menu-panel hidden">
<div class="map-layer-menu-title">Map Layers</div>
<div class="map-controls-toggle-grid">
</select></label>
<div class="map-layer-menu-title">Layers</div>
<div class="map-controls-toggle-grid">
<label class="toggle-control">
<span class="toggle-label">📡 Radar</span>
<span class="switch-toggle">
@@ -340,7 +292,7 @@
</span>
</label>
<label class="toggle-control">
<span class="toggle-label">☁️ Clouds</span>
<span class="toggle-label">🛰 Satellite (IR)</span>
<span class="switch-toggle">
<input type="checkbox" id="toggle-satellite" data-layer="satellite">
<span class="toggle-slider"></span>
@@ -410,36 +362,10 @@
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Main Map -->
<div class="unified-map-shell">
<div id="unified-map" class="unified-map-container" style="height:56vh; min-height:420px;"></div>
<div class="map-overlay-readouts" id="map-overlay-readouts">
<span class="map-readout-chip" id="overlay-temp">Temp --</span>
<span class="map-readout-chip" id="overlay-aqi">AQI --</span>
<span class="map-readout-chip" id="overlay-sunmoon">Sun -- • Moon --</span>
</div>
</div>
<!-- Radar/Satellite Timeline Controls -->
<div class="map-timeline-wrap hidden" id="timeline-controls">
<div class="radar-controls">
<button id="timeline-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
<div class="radar-timeline" id="timeline-slider"></div>
<span id="timeline-timestamp" class="radar-ts">--</span>
</div>
<div class="radar-legend">
<span class="legend-label">Light</span>
<div class="legend-bar"></div>
<span class="legend-label">Heavy</span>
</div>
</div>
<!-- Data Panel -->
<div class="map-data-panel hidden" id="data-panel">
<div class="map-bottom">
<div class="map-data-panel glass hidden" id="data-panel">
<div class="panel-location">
<span id="panel-location-name">--</span>
</div>
@@ -465,6 +391,20 @@
<span id="panel-cloud">--</span>
</div>
</div>
</div>
<div class="map-timeline-wrap glass hidden" id="timeline-controls">
<div class="radar-controls">
<button id="timeline-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
<div class="radar-timeline" id="timeline-slider"></div>
<span id="timeline-timestamp" class="radar-ts">--</span>
</div>
<div class="radar-legend">
<span class="legend-label">Light</span>
<div class="legend-bar"></div>
<span class="legend-label">Heavy</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -490,9 +430,12 @@
<div id="tab-settings" class="tab-panel hidden">
<div class="settings-wrap">
<div class="settings-section-label">Install the app</div>
<div id="install-card" class="install-card"></div>
<div class="settings-section-label">Station</div>
<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>
</div>
@@ -506,6 +449,7 @@
<button id="units-btn" title="Toggle units">US</button>
</div>
<ul id="suggestions" class="suggestions hidden"></ul>
<p id="geo-status" class="geo-status hidden" role="status"></p>
<p id="error-msg" class="error hidden"></p>
</div>
@@ -1336,6 +1280,10 @@
<span class="bottom-icon">⛅</span>
<span class="bottom-label">Now</span>
</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">
<span class="bottom-icon">🗺️</span>
<span class="bottom-label">Map</span>
@@ -1345,18 +1293,18 @@
<span class="bottom-label">Alerts</span>
<span class="alert-badge hidden" id="alert-badge-bottom"></span>
</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>
<dialog id="install-dialog" class="install-dialog" aria-label="How to install the app">
<div class="install-dialog-head"><h2>Install the app</h2><button type="button" id="install-dialog-close" aria-label="Close">✕</button></div>
<div id="install-dialog-body"></div>
</dialog>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="alert-info.js"></script>
<script src="app.js"></script>
<script src="station-live.js"></script>
<script src="app-ui.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
+62 -13
View File
@@ -5,26 +5,44 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#191510",
"theme_color": "#191510",
"display_override": [
"window-controls-overlay",
"standalone"
],
"background_color": "#070c14",
"theme_color": "#070c14",
"orientation": "portrait-primary",
"categories": ["weather", "utilities"],
"categories": [
"weather",
"utilities"
],
"icons": [
{
"src": "/icons/icon-192.png",
"src": "/icons/v2/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"src": "/icons/v2/apple-touch-icon-180.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/icons/icon.svg",
"src": "/icons/v2/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/v2/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/v2/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
@@ -33,7 +51,11 @@
"share_target": {
"action": "/?share",
"method": "GET",
"params": { "title": "title", "text": "text", "url": "url" }
"params": {
"title": "title",
"text": "text",
"url": "url"
}
},
"shortcuts": [
{
@@ -41,21 +63,48 @@
"short_name": "Now",
"description": "View current weather and forecast",
"url": "/?tab=conditions",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
"icons": [
{
"src": "/icons/v2/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "My Station",
"short_name": "Station",
"description": "Live readings from your weather station",
"url": "/?tab=station",
"icons": [
{
"src": "/icons/v2/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Radar Map",
"short_name": "Map",
"description": "Live radar and weather map",
"url": "/?tab=unified-map",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
"icons": [
{
"src": "/icons/v2/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Active Alerts",
"short_name": "Alerts",
"description": "Weather warnings and alerts",
"url": "/?tab=alerts",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
"icons": [
{
"src": "/icons/v2/icon-192.png",
"sizes": "192x192"
}
]
}
]
}
+28
View File
@@ -0,0 +1,28 @@
/* Shared PWA safe-area rules: notch / Dynamic Island, rounded corners, home indicator (iPhone 14–17 etc.).
Needs <meta name="viewport" content="..., viewport-fit=cover"> on the page. */
/* Installed iPhone apps draw under the status bar with white text (black-translucent).
A dark cap behind that zone keeps the clock and battery readable on light themes too. */
@media (display-mode: standalone) {
body::before {
content: ''; position: fixed; top: 0; left: 0; right: 0; z-index: 100000; pointer-events: none;
height: env(safe-area-inset-top, 0px); background: #070c14;
}
}
/* Never let page content slide under the sensor housing or rounded corners in landscape */
/* (main app and dashboard add side insets to their own containers; simple pages use .safe-sides) */
.safe-sides { padding-left: env(safe-area-inset-left, 0px); padding-right: env(safe-area-inset-right, 0px); }
/* Fixed overlays that hug the top / sides / bottom */
.offline-banner { padding-top: calc(0.4rem + env(safe-area-inset-top, 0px)); }
.ptr-indicator { top: env(safe-area-inset-top, 0px); }
.learn-back-top { right: max(1rem, calc(0.6rem + env(safe-area-inset-right, 0px))); }
/* Bottom bars: clear the home indicator and the rounded bottom corners */
.bottom-nav { padding-left: max(8px, env(safe-area-inset-left, 0px)); padding-right: max(8px, env(safe-area-inset-right, 0px)); padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); }
/* (Full-screen map: the map runs edge to edge; its floating controls add the insets themselves) */
/* Modal dialogs: stay inside the safe area on short (landscape) screens */
dialog { max-height: calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 1.5rem); overflow: auto; }
+92
View File
@@ -0,0 +1,92 @@
/* 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); }
/* What is this station? */
.sl-about { background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 14px; margin-bottom: 0.9rem; overflow: hidden; }
.sl-about summary { display: flex; align-items: center; gap: 0.6rem; min-height: 48px; padding: 0 1rem; cursor: pointer; list-style: none; font-size: 0.9rem; font-weight: 600; color: var(--sl-text); }
.sl-about summary::-webkit-details-marker { display: none; }
.sl-about-ico { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--sl-accent) 18%, transparent); color: var(--sl-accent); font-size: 0.95rem; }
.sl-about-chev { margin-left: auto; color: var(--sl-muted); font-size: 1.2rem; transition: transform .2s; }
.sl-about[open] .sl-about-chev { transform: rotate(90deg); }
.sl-about-body { padding: 0 1rem 1rem; font-size: 0.88rem; line-height: 1.55; color: var(--sl-text); }
.sl-about-body p { margin: 0 0 0.6rem; }
.sl-about-h { font-size: 0.62rem; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; color: var(--sl-muted); margin: 0.9rem 0 0.35rem !important; }
.sl-sensors { margin: 0 0 0.3rem; padding: 0; list-style: none; display: grid; gap: 0.4rem; }
.sl-sensors li { padding-left: 0; color: var(--sl-text); }
.sl-about-foot { margin-top: 0.8rem !important; padding-top: 0.7rem; border-top: 1px solid var(--sl-border); font-size: 0.8rem; color: var(--sl-muted); }
.sl-about-foot a { color: var(--sl-accent); font-weight: 600; }
+283
View File
@@ -0,0 +1,283 @@
/* 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>
<details class="sl-about" id="sl-about">
<summary><span class="sl-about-ico">ⓘ</span><span>What is this station?</span><span class="sl-about-chev">›</span></summary>
<div class="sl-about-body">
<p><b id="sl-about-name">This station</b> is a <b>personal weather station</b>: real instruments on the owner's property that measure the weather <i>right here, right now</i>. These numbers are measurements, not a forecast or a computer model.</p>
<p class="sl-about-h">What it measures</p>
<ul id="sl-sensors" class="sl-sensors"><li>Waiting for the first reading…</li></ul>
<p class="sl-about-h">How it fits with the rest of the app</p>
<ul class="sl-sensors">
<li><b>Now</b> shows a forecast model for the city you picked. <b>Station</b> shows what one spot is actually measuring.</li>
<li>When you see <b>📡 Your station</b> on the Now tab, its temperature, wind and rain come from this station.</li>
<li>It reports about every minute or two. <b>Live</b> means the last reading is under 15 minutes old; <b>Offline</b> means older.</li>
<li>Because it's one spot, it can differ a little from the airport or the forecast, and that's normal.</li>
</ul>
<p class="sl-about-foot">History, records and charts, plus a side-by-side with the official National Weather Service station, are on the <a href="/dashboard">Station Dashboard</a> (sign-in required).</p>
</div>
</details>
<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;
// "What is this station?" starts open for new visitors and stays closed once dismissed
function initAbout() {
const d = $('sl-about');
if (!d) return;
let closed = false; try { closed = localStorage.getItem('stationAboutClosed') === '1'; } catch {}
d.open = !closed;
d.addEventListener('toggle', () => { try { localStorage.setItem('stationAboutClosed', d.open ? '0' : '1'); } catch {} });
}
function ensureShell() {
if (shellReady) return;
root.innerHTML = SHELL;
shellReady = true;
initAbout();
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);
// Describe the instruments this particular station really has
set('sl-about-name', name);
const has = (...k) => k.some((f) => d[f] != null);
const sensors = [
has('tempf', 'humidity') && '🌡 <b>Temperature &amp; humidity</b>: a shaded outdoor sensor',
has('windspeedmph', 'winddir', 'windgustmph') && '💨 <b>Wind</b>: speed, gusts and direction (anemometer and wind vane)',
has('dailyrainin', 'rainratein') && '🌧 <b>Rain</b>: a rain gauge that tips with each bit of rain',
has('baromrelin') && '🧭 <b>Pressure</b>: a barometer (falling pressure often means weather is coming)',
has('uv', 'solarradiation') && '☀️ <b>Sun</b>: UV and solar-radiation sensors',
has('tempinf', 'humidityin') && '🏠 <b>Indoor</b>: a sensor inside the house',
].filter(Boolean);
if (sensors.length) $('sl-sensors').innerHTML = sensors.map((x) => `<li>${x}</li>`).join('');
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', 'This tab shows live readings from a <b>personal weather station</b> (a real device on someone\'s property that measures conditions on the spot, not a forecast). Add your Ambient Weather API keys in Settings to connect yours.');
} 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>';
})();
+36 -49
View File
@@ -1,15 +1,15 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a;
--ink-mid: #3d3730;
--ink-light: #5f5649;
--paper: #f5f2ec;
--paper-dark: #ece7db;
--paper-darker: #ddd6c7;
--border: #b9ae9b;
--accent: #c25a2a;
--cool: #2a6b8a;
--ink: #0d1b2a;
--ink-mid: #2b3f55;
--ink-light: #5a7089;
--paper: #edf2f7;
--paper-dark: #ffffff;
--paper-darker: #e3eaf2;
--border: #ccd6e2;
--accent: #2563eb;
--cool: #0e7490;
--mono: '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);
@@ -25,28 +25,28 @@
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ink: #ece7dc;
--ink-mid: #d2c8b1;
--ink-light: #beb399;
--paper: #191510;
--paper-dark: #201c16;
--paper-darker: #2b2620;
--border: #3a342a;
--accent: #e07848;
--cool: #5ab0d8;
--ink: #dce6f0;
--ink-mid: #b7c6d6;
--ink-light: #8aa2b8;
--paper: #070c14;
--paper-dark: #0d1720;
--paper-darker: #101c29;
--border: #22405c;
--accent: #60a5fa;
--cool: #38bdf8;
}
}
:root[data-theme="dark"] {
--ink: #ece7dc;
--ink-mid: #d2c8b1;
--ink-light: #beb399;
--paper: #191510;
--paper-dark: #201c16;
--paper-darker: #2b2620;
--border: #3a342a;
--accent: #e07848;
--cool: #5ab0d8;
--ink: #dce6f0;
--ink-mid: #b7c6d6;
--ink-light: #8aa2b8;
--paper: #070c14;
--paper-dark: #0d1720;
--paper-darker: #101c29;
--border: #22405c;
--accent: #60a5fa;
--cool: #38bdf8;
}
html, body {
@@ -366,7 +366,7 @@ a:hover { text-decoration: underline; }
.map-location-dot {
width: 12px;
height: 12px;
background: #e07848;
background: #60a5fa;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
@@ -2429,13 +2429,14 @@ body.map-fs #unified-map {
font-weight: 700;
color: #fff;
}
.cat-td { background: #2196f3; }
.cat-ts { background: #9c27b0; }
.cat-1 { background: #ff9800; }
.cat-2 { background: #f44336; }
.cat-3 { background: #b71c1c; }
.cat-4 { background: #7b1fa2; }
.cat-5 { background: #1a237e; }
.cat-td { background: #5dade2; }
.cat-ts { background: #27ae60; }
.cat-1 { background: #f1c40f; color: #1a1a1a; }
.cat-2 { background: #f39c12; }
.cat-3 { background: #e74c3c; }
.cat-4 { background: #c0392b; }
.cat-5 { background: #9b59b6; }
.cat-ex { background: #95a5a6; }
.hurricane-stats {
display: flex;
@@ -2532,20 +2533,6 @@ body.map-fs #unified-map {
/* ── 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 {
display: inline-flex;
align-items: center;
+12 -6
View File
@@ -4,15 +4,21 @@ self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting();
});
const CACHE = 'weather-v7';
const CACHE = 'weather-v15';
// App shell cached on install for offline resilience.
const PRECACHE = [
'/',
'/app.js',
'/style.css',
'/station-live.css',
'/station-live.js',
'/app-ui.css',
'/pwa-safe.css',
'/alert-info.js',
'/app-ui.js',
'/manifest.json',
'/icons/icon.svg'
'/icons/v2/icon.svg'
];
self.addEventListener('install', event => {
@@ -38,8 +44,8 @@ self.addEventListener('push', event => {
event.waitUntil(
self.registration.showNotification(payload.title || 'NeCloud Weather', {
body: payload.body || '',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
icon: '/icons/v2/icon-192.png',
badge: '/icons/v2/icon-192.png',
tag: payload.tag || 'weather-alert',
data: { url: payload.url || '/' },
})
@@ -95,14 +101,14 @@ self.addEventListener('fetch', event => {
}
return response;
})
.catch(() => caches.match(request).then(cached => cached || caches.match('/')))
.catch(() => caches.match(request, { ignoreSearch: true }).then(cached => cached || caches.match('/', { ignoreSearch: true })))
);
return;
}
// Cache-first for static assets.
event.respondWith(
caches.match(request).then(cached => {
caches.match(request, { ignoreSearch: true }).then(cached => {
if (cached) return cached;
return fetch(request).then(response => {
if (response.ok && (
+1127 -201
View File
File diff suppressed because it is too large. Load diff