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>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:45:47 -04:00
1 parent 7dea2734ea
commit 1830fb2e2b
11 files changed
+361 -917

No files matched your search

+10 -89
View File
@@ -2658,7 +2658,7 @@ syncOfflineState();
// ── Swipe gestures for tab switching ──────────────────────────────────────────
(function initSwipe() {
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
const TABS = ['conditions', 'station', 'unified-map', 'alerts', 'learn', 'settings'];
let touchX = 0;
let touchY = 0;
let blocked = false;
@@ -2813,7 +2813,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 +2970,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 +2983,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 +3009,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 +3069,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) {
@@ -3113,73 +3099,8 @@ function applyStationOverlay(device) {
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>
+1 -1
View File
@@ -136,7 +136,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">
+1 -1
View File
@@ -384,7 +384,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
</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>
+16 -64
View File
@@ -13,7 +13,11 @@
<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">
</head>
<body>
<div id="offline-banner" class="offline-banner hidden">No internet connection — showing cached data</div>
@@ -24,6 +28,7 @@
<h1>NeCloud Weather</h1>
<nav class="tabs">
<button class="tab active" data-tab="conditions">Conditions</button>
<button class="tab" data-tab="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>
@@ -31,7 +36,6 @@
</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>
</header>
@@ -242,68 +246,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,6 +255,11 @@
</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>
<div id="tab-unified-map" class="tab-panel hidden">
<div class="unified-map-wrap">
<!-- Map Controls -->
@@ -492,7 +439,7 @@
<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>
@@ -1336,6 +1283,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>
@@ -1357,6 +1308,7 @@
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="app.js"></script>
<script src="station-live.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
+7
View File
@@ -43,6 +43,13 @@
"url": "/?tab=conditions",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
},
{
"name": "My Station",
"short_name": "Station",
"description": "Live readings from your weather station",
"url": "/?tab=station",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Radar Map",
"short_name": "Map",
+77
View File
@@ -0,0 +1,77 @@
/* Station tab — the former /conditions page look, scoped so it can't touch the rest of the app */
.sl {
--sl-bg: #edf2f7; --sl-surface: #ffffff; --sl-card: #f4f7fb; --sl-border: #ccd6e2;
--sl-text: #0d1b2a; --sl-muted: #5a7089; --sl-accent: #2563eb;
--sl-dot: rgba(37,99,235,0.065); --sl-live: #16a34a; --sl-north: #dc2626;
font-family: 'Barlow', system-ui, sans-serif; color: var(--sl-text);
max-width: 720px; margin: 0 auto; padding: 0.25rem 0 2.5rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .sl {
--sl-bg: #070c14; --sl-surface: #0d1720; --sl-card: #101c29; --sl-border: #192e44;
--sl-text: #dce6f0; --sl-muted: #6f8aa3; --sl-accent: #60a5fa;
--sl-dot: rgba(96,165,250,0.07); --sl-live: #4ade80; --sl-north: #f87171;
}
}
:root[data-theme="dark"] .sl {
--sl-bg: #070c14; --sl-surface: #0d1720; --sl-card: #101c29; --sl-border: #192e44;
--sl-text: #dce6f0; --sl-muted: #6f8aa3; --sl-accent: #60a5fa;
--sl-dot: rgba(96,165,250,0.07); --sl-live: #4ade80; --sl-north: #f87171;
}
.sl *, .sl *::before, .sl *::after { box-sizing: border-box; }
/* Status bar */
.sl-bar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.7rem 1rem; margin-bottom: 0.9rem;
background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 14px; }
.sl-name { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sl-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sl-live { display: flex; align-items: center; gap: 0.45rem; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sl-live); white-space: nowrap; }
.sl-live.stale { color: var(--sl-muted); }
.sl-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; animation: sl-pulse 2.4s ease-in-out infinite; }
.sl-live.stale .sl-dot { animation: none; opacity: .5; }
@keyframes sl-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }
.sl-time { font-size: 0.78rem; color: var(--sl-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Hero */
.sl-hero { position: relative; background: var(--sl-surface); padding: 3rem 1.25rem 2.75rem; text-align: center; overflow: hidden; border: 1px solid var(--sl-border); border-radius: 18px; }
.sl-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(circle, var(--sl-dot) 1.5px, transparent 1.5px); background-size: 28px 28px; pointer-events: none; }
.sl-hero-sweep { position: absolute; top: 50%; left: 50%; width: 320px; height: 320px; margin: -160px 0 0 -160px; border-radius: 50%;
background: conic-gradient(from 0deg, transparent 78%, var(--sl-accent) 100%); opacity: .18; animation: sl-sweep 14s linear infinite; pointer-events: none; }
.sl-hero-ring, .sl-hero-ring2 { position: absolute; top: 50%; left: 50%; border-radius: 50%; border: 1px solid var(--sl-border); pointer-events: none; }
.sl-hero-ring { width: 400px; height: 400px; margin: -200px 0 0 -200px; }
.sl-hero-ring2 { width: 240px; height: 240px; margin: -120px 0 0 -120px; }
@keyframes sl-sweep { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .sl-hero-sweep, .sl-dot { animation: none; } }
.sl-hero-body { position: relative; z-index: 1; }
.sl-temp-row { display: inline-flex; align-items: flex-start; line-height: 1; font-family: 'Barlow Condensed', sans-serif; font-weight: 800; letter-spacing: -0.03em; }
.sl-temp-num { font-size: clamp(5.5rem, 22vw, 9rem); line-height: 1; transition: color .5s ease; }
.sl-temp-deg { font-size: clamp(1.8rem, 7vw, 3rem); margin-top: .15em; margin-left: .05em; opacity: .6; transition: color .5s ease; }
.sl-hero-sub { margin-top: .45rem; font-size: .95rem; color: var(--sl-muted); }
.sl-pill { display: inline-flex; align-items: center; gap: .35rem; margin-top: .85rem; padding: .3rem 1rem; background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 999px; font-size: .82rem; font-weight: 500; color: var(--sl-muted); }
/* Sections */
.sl-section { margin-top: 1.4rem; }
.sl-label { font-size: .62rem; font-weight: 600; letter-spacing: .11em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .6rem; }
.sl-row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: .7rem; }
.sl-row4 { display: grid; grid-template-columns: repeat(2, 1fr); gap: .7rem; }
.sl-row2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: .7rem; }
@media (min-width: 480px) { .sl-row4 { grid-template-columns: repeat(4, 1fr); } }
.sl-card { background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px; padding: 1rem 1.1rem .9rem; min-width: 0; }
.sl-card-label { font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .3rem; }
.sl-card-val { font-family: 'Barlow Condensed', sans-serif; font-size: 2rem; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.sl-card-unit { font-size: .68rem; color: var(--sl-muted); margin-top: .2rem; }
/* Wind */
.sl-wind { background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px; padding: 1.2rem; display: flex; align-items: center; gap: 1.5rem; }
.sl-compass { flex-shrink: 0; display: block; }
.sl-wind-grid { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1.25rem; }
.sl-wind-label { font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .2rem; }
.sl-wind-val { font-family: 'Barlow Condensed', sans-serif; font-size: 1.75rem; font-weight: 700; line-height: 1; }
.sl-wind-unit { font-size: .65rem; color: var(--sl-muted); }
@media (max-width: 460px) { .sl-wind { flex-direction: column; } }
.sl-foot { text-align: center; font-size: .7rem; color: var(--sl-muted); margin-top: 1.5rem; }
.sl-empty { text-align: center; padding: 3rem 1.25rem; background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 18px; color: var(--sl-muted); }
.sl-empty b { display: block; font-family: 'Barlow Condensed', sans-serif; font-size: 1.6rem; color: var(--sl-text); margin-bottom: .4rem; }
.sl-empty button { margin-top: 1rem; font: inherit; font-weight: 600; padding: .6rem 1.2rem; min-height: 44px; border-radius: 10px; border: 1px solid var(--sl-accent); background: transparent; color: var(--sl-accent); cursor: pointer; }
.sl-link { display: inline-flex; align-items: center; gap: .3rem; font-size: .78rem; font-weight: 600; color: var(--sl-muted); text-decoration: none; padding: .32rem .75rem; border: 1px solid var(--sl-border); border-radius: 8px; }
.sl-link:hover { color: var(--sl-accent); border-color: var(--sl-accent); }
+243
View File
@@ -0,0 +1,243 @@
/* Station tab: live readings from the personal weather station, in the former /conditions look.
app.js calls renderStationTab(device) whenever new station data arrives. */
(() => {
'use strict';
const root = document.getElementById('station-live');
if (!root) return;
const cardinal = (deg) => (deg == null ? '—' : ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(+deg / 22.5) % 16]);
const metric = () => typeof state !== 'undefined' && state.units !== 'us';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const fTemp = (f) => (num(f) == null ? '—' : String(Math.round(metric() ? (f - 32) * 5 / 9 : f)));
const fSpeed = (m) => (num(m) == null ? '—' : String(Math.round(metric() ? m * 1.60934 : m)));
const fRain = (i) => (num(i) == null ? '—' : metric() ? (i * 25.4).toFixed(1) : i.toFixed(2));
const fPres = (i) => (num(i) == null ? '—' : metric() ? String(Math.round(i * 33.8639)) : i.toFixed(2));
const units = () => ({ t: metric() ? '°C' : '°F', s: metric() ? 'km/h' : 'mph', r: metric() ? 'mm' : 'in', p: metric() ? 'hPa' : 'inHg' });
function tempColor(f) {
if (f == null) return 'var(--sl-text)';
if (f <= 32) return '#93c5fd';
if (f <= 50) return '#60a5fa';
if (f <= 65) return 'var(--sl-text)';
if (f <= 80) return '#f59e0b';
if (f <= 90) return '#f97316';
return '#ef4444';
}
function uvInfo(uv) {
if (uv == null) return ['', 'var(--sl-text)'];
if (uv < 3) return ['Low', '#4ade80'];
if (uv < 6) return ['Moderate', '#facc15'];
if (uv < 8) return ['High', '#f97316'];
if (uv < 11) return ['Very High', '#ef4444'];
return ['Extreme', '#d946ef'];
}
function conditionText(d) {
if (!d) return '—';
if (d.rainratein > 0.05) return '🌧 Raining';
const s = d.solarradiation;
if (s != null) {
if (s > 700) return '☀️ Sunny';
if (s > 250) return '🌤 Partly Cloudy';
if (s > 60) return '⛅ Mostly Cloudy';
if (s > 5) return '☁️ Overcast';
}
return '🌙 Night';
}
const SHELL = `
<div class="sl-bar">
<span class="sl-name" id="sl-name">My Station</span>
<span class="sl-live" id="sl-live"><span class="sl-dot"></span><span id="sl-live-text">Live</span></span>
<span><span class="sl-time" id="sl-time"></span></span>
</div>
<section class="sl-hero">
<div class="sl-hero-dots"></div><div class="sl-hero-sweep"></div><div class="sl-hero-ring"></div><div class="sl-hero-ring2"></div>
<div class="sl-hero-body">
<div class="sl-temp-row"><div class="sl-temp-num" id="sl-temp">—</div><div class="sl-temp-deg" id="sl-deg"></div></div>
<div class="sl-hero-sub" id="sl-sub"></div>
<div><span class="sl-pill" id="sl-pill">Loading…</span></div>
</div>
</section>
<div class="sl-section"><div class="sl-label">Atmosphere</div>
<div class="sl-row3">
<div class="sl-card"><div class="sl-card-label">Humidity</div><div class="sl-card-val" id="sl-hum">—</div><div class="sl-card-unit">%</div></div>
<div class="sl-card"><div class="sl-card-label">Dew Point</div><div class="sl-card-val" id="sl-dew">—</div><div class="sl-card-unit" id="sl-dew-u">°F</div></div>
<div class="sl-card"><div class="sl-card-label">Pressure</div><div class="sl-card-val" id="sl-pres">—</div><div class="sl-card-unit" id="sl-pres-u">inHg</div></div>
</div>
</div>
<div class="sl-section"><div class="sl-label">Wind</div>
<div class="sl-wind">
<canvas class="sl-compass" id="sl-compass" width="152" height="152" style="width:152px;height:152px"></canvas>
<div class="sl-wind-grid">
<div><div class="sl-wind-label">Speed</div><div class="sl-wind-val" id="sl-ws">—</div><div class="sl-wind-unit sl-su">mph</div></div>
<div><div class="sl-wind-label">Gust</div><div class="sl-wind-val" id="sl-wg">—</div><div class="sl-wind-unit sl-su">mph</div></div>
<div><div class="sl-wind-label">From</div><div class="sl-wind-val" id="sl-wd">—</div><div class="sl-wind-unit" id="sl-wdeg"></div></div>
<div><div class="sl-wind-label">Max Today</div><div class="sl-wind-val" id="sl-wm">—</div><div class="sl-wind-unit sl-su">mph</div></div>
</div>
</div>
</div>
<div class="sl-section"><div class="sl-label">Precipitation &amp; Solar</div>
<div class="sl-row4">
<div class="sl-card"><div class="sl-card-label">Rain Today</div><div class="sl-card-val" id="sl-rain">—</div><div class="sl-card-unit sl-ru">in</div></div>
<div class="sl-card"><div class="sl-card-label">Rain Rate</div><div class="sl-card-val" id="sl-rate">—</div><div class="sl-card-unit sl-rru">in / hr</div></div>
<div class="sl-card"><div class="sl-card-label">UV Index</div><div class="sl-card-val" id="sl-uv">—</div><div class="sl-card-unit" id="sl-uvl"></div></div>
<div class="sl-card"><div class="sl-card-label">Solar Rad.</div><div class="sl-card-val" id="sl-solar">—</div><div class="sl-card-unit">W/m²</div></div>
</div>
</div>
<div class="sl-section" id="sl-indoor-sec" hidden><div class="sl-label">Indoor</div>
<div class="sl-row2">
<div class="sl-card"><div class="sl-card-label">Temperature</div><div class="sl-card-val" id="sl-tin">—</div><div class="sl-card-unit sl-tu">°F</div></div>
<div class="sl-card"><div class="sl-card-label">Humidity</div><div class="sl-card-val" id="sl-hin">—</div><div class="sl-card-unit">%</div></div>
</div>
</div>
<div class="sl-foot"><span id="sl-foot">Refreshes automatically every minute</span> · <a class="sl-link" href="/dashboard">Dashboard →</a></div>`;
const EMPTY = (title, text) => `
<div class="sl-empty"><b>${title}</b>${text}<div><button type="button" id="sl-goto-settings">Open Settings</button></div></div>`;
const $ = (id) => document.getElementById(id);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
const setAll = (sel, v) => root.querySelectorAll(sel).forEach((el) => { el.textContent = v; });
let shellReady = false;
let lastDir = null;
function ensureShell() {
if (shellReady) return;
root.innerHTML = SHELL;
shellReady = true;
drawCompass(null, null);
}
function showMessage(title, text) {
shellReady = false;
root.innerHTML = EMPTY(title, text);
$('sl-goto-settings')?.addEventListener('click', () => { if (typeof activateTab === 'function') activateTab('settings'); });
}
function drawCompass(dir, speedText) {
const canvas = $('sl-compass');
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const size = 152;
canvas.width = size * dpr; canvas.height = size * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cs = getComputedStyle(root);
const v = (n, f) => cs.getPropertyValue(n).trim() || f;
const cCard = v('--sl-surface', '#fff'), cRing = v('--sl-border', '#ccd6e2'), cLabel = v('--sl-muted', '#5a7089');
const cAccent = v('--sl-accent', '#2563eb'), cNorth = v('--sl-north', '#dc2626');
const cx = size / 2, cy = size / 2, r = size / 2 - 7;
ctx.fillStyle = cCard; ctx.beginPath(); ctx.arc(cx, cy, r + 5, 0, Math.PI * 2); ctx.fill();
[1 / 3, 2 / 3, 1].forEach((f) => { ctx.beginPath(); ctx.arc(cx, cy, r * f, 0, Math.PI * 2); ctx.strokeStyle = cRing; ctx.lineWidth = 0.7; ctx.stroke(); });
for (let i = 0; i < 8; i++) {
const a = ((i * 45 - 90) * Math.PI) / 180, major = i % 2 === 0, len = major ? 9 : 5;
ctx.beginPath(); ctx.moveTo(cx + r * Math.cos(a), cy + r * Math.sin(a));
ctx.lineTo(cx + (r - len) * Math.cos(a), cy + (r - len) * Math.sin(a));
ctx.strokeStyle = major ? cLabel : cRing; ctx.lineWidth = major ? 1.5 : 0.7; ctx.stroke();
}
[['N', -90, cNorth], ['E', 0, cLabel], ['S', 90, cLabel], ['W', 180, cLabel]].forEach(([l, deg, color]) => {
const a = (deg * Math.PI) / 180;
ctx.font = `700 ${l === 'N' ? 11 : 10}px 'Barlow', sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = color;
ctx.fillText(l, cx + (r - 18) * Math.cos(a), cy + (r - 18) * Math.sin(a));
});
if (dir != null) {
const a = ((+dir - 90) * Math.PI) / 180, tip = r * 0.6;
const tx = cx + tip * Math.cos(a), ty = cy + tip * Math.sin(a);
ctx.strokeStyle = cAccent; ctx.lineWidth = 2; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty); ctx.stroke();
const ha = Math.atan2(ty - cy, tx - cx), hl = 13;
ctx.fillStyle = cAccent; ctx.beginPath(); ctx.moveTo(tx, ty);
ctx.lineTo(tx - hl * Math.cos(ha - 0.38), ty - hl * Math.sin(ha - 0.38));
ctx.lineTo(tx - hl * 0.55 * Math.cos(ha), ty - hl * 0.55 * Math.sin(ha));
ctx.lineTo(tx - hl * Math.cos(ha + 0.38), ty - hl * Math.sin(ha + 0.38));
ctx.closePath(); ctx.fill();
const ta = a + Math.PI;
ctx.globalAlpha = 0.3; ctx.lineWidth = 1.5; ctx.strokeStyle = cAccent;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + r * 0.22 * Math.cos(ta), cy + r * 0.22 * Math.sin(ta)); ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = cAccent; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
}
}
window.renderStationTab = function renderStationTab(device) {
const d = device?.lastData;
if (!d) return;
ensureShell();
const u = units();
const name = (typeof state !== 'undefined' && state.stationName) || device.info?.name || 'My Station';
set('sl-name', name);
const fresh = d.dateutc && Date.now() - d.dateutc < 15 * 60 * 1000;
const live = $('sl-live');
live.classList.toggle('stale', !fresh);
set('sl-live-text', fresh ? 'Live' : 'Offline');
if (d.dateutc) {
const t = new Date(d.dateutc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
set('sl-time', t);
set('sl-foot', `Updated ${t} · refreshes automatically`);
}
const tempEl = $('sl-temp'), degEl = $('sl-deg');
tempEl.textContent = fTemp(d.tempf);
degEl.textContent = d.tempf != null ? u.t : '';
const col = tempColor(d.tempf);
tempEl.style.color = col; degEl.style.color = col;
const fl = d.feelsLike ?? d.tempf;
set('sl-sub', fl != null ? `Feels like ${fTemp(fl)}${u.t}` : '');
set('sl-pill', fresh ? conditionText(d) : 'Station offline');
set('sl-hum', d.humidity != null ? String(Math.round(d.humidity)) : '—');
set('sl-dew', fTemp(d.dewPoint)); set('sl-dew-u', u.t);
set('sl-pres', fPres(d.baromrelin)); set('sl-pres-u', u.p);
set('sl-ws', fSpeed(d.windspeedmph)); set('sl-wg', fSpeed(d.windgustmph)); set('sl-wm', fSpeed(d.maxdailygust));
set('sl-wd', cardinal(d.winddir)); set('sl-wdeg', d.winddir != null ? Math.round(+d.winddir) + '°' : '');
setAll('.sl-su', u.s);
lastDir = d.winddir ?? null;
drawCompass(lastDir);
set('sl-rain', fRain(d.dailyrainin)); set('sl-rate', fRain(d.rainratein));
setAll('.sl-ru', u.r); setAll('.sl-rru', `${u.r} / hr`);
const [uvLabel, uvColor] = uvInfo(d.uv);
set('sl-uv', d.uv != null ? String(Math.round(d.uv)) : '—');
$('sl-uv').style.color = uvColor; set('sl-uvl', uvLabel);
set('sl-solar', d.solarradiation != null ? String(Math.round(d.solarradiation)) : '—');
const sec = $('sl-indoor-sec');
if (d.tempinf != null) {
sec.hidden = false;
set('sl-tin', fTemp(d.tempinf)); setAll('.sl-tu', u.t);
set('sl-hin', d.humidityin != null ? String(Math.round(d.humidityin)) : '—');
} else sec.hidden = true;
};
// Called by app.js when the station can't be reached / isn't configured
window.stationTabMessage = function (kind, detail) {
if (shellReady) {
// Keep showing the last readings but flag them as stale
const live = $('sl-live'); if (live) { live.classList.add('stale'); set('sl-live-text', 'Offline'); }
return;
}
if (kind === 'unconfigured') {
showMessage('No station connected', 'Add your Ambient Weather API keys in Settings to see live readings from your own station here.');
} else {
showMessage('Station unavailable', detail || 'Could not reach your station right now. It will keep retrying.');
}
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => shellReady && drawCompass(lastDir));
new MutationObserver(() => shellReady && drawCompass(lastDir)).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
root.innerHTML = '<div class="sl-empty"><b>Loading…</b>Fetching your station readings.</div>';
})();
-14
View File
@@ -2532,20 +2532,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;
+3 -1
View File
@@ -4,13 +4,15 @@ self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting();
});
const CACHE = 'weather-v7';
const CACHE = 'weather-v8';
// App shell cached on install for offline resilience.
const PRECACHE = [
'/',
'/app.js',
'/style.css',
'/station-live.css',
'/station-live.js',
'/manifest.json',
'/icons/icon.svg'
];