feat: frontend design A with scaling, formatting, refresh and error states

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:43:37 +02:00
co-authored by Claude Fable 5.1
parent 6ef2d3f060
commit c65bc1d957
3 changed files with 459 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
} from './format.js';
import { icon } from './icons.js';
const CANVAS_W = 1133;
const CANVAS_H = 744;
const API_URL = 'api/data.php';
const REFRESH_MS = 5 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10000;
const STALE_MINUTES = 30;
const RELOAD_HOUR = 4;
const FALLBACK_TZ = 'Europe/Berlin';
const $ = (id) => document.getElementById(id);
const stage = $('stage');
const viewport = $('viewport');
const state = {
data: null, // laatste payload, ook als die uit de SW-cache komt
tz: FALLBACK_TZ,
lastOk: null, // Date van de laatste geslaagde live-fetch
offlineSince: null // Date vanaf wanneer er geen verbinding is
};
/* ---------- schaal ---------- */
function fit() {
const w = viewport.clientWidth;
const h = viewport.clientHeight;
const s = Math.min(w / CANVAS_W, h / CANVAS_H);
const x = Math.round((w - CANVAS_W * s) / 2);
const y = Math.round((h - CANVAS_H * s) / 2);
stage.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
}
/* ---------- render ---------- */
function setText(id, text) {
$(id).textContent = text;
}
function renderHeader(now) {
setText('date', formatDate(now, state.tz));
setText('clock', formatTime(now, state.tz));
}
function renderMeasurement(now) {
const d = state.data;
const upd = $('upd');
if (!d) return;
const stale = d.station.stale || isOlderThan(d.station.measured_at, now, STALE_MINUTES);
upd.textContent = `meting ${formatTime(d.station.measured_at, state.tz)}${stale ? ' · verouderd' : ''}`;
upd.classList.toggle('stale', stale);
}
function renderOutdoor(o) {
setText('trend', trendLabel(o.trend));
setText('temp', `${f1(o.temp)}°`);
$('mark').style.left = `${markerPos(o.temp, o.min, o.max).toFixed(1)}%`;
setText('min', `${f1(o.min)}°`);
setText('min-at', `laagst om ${formatTime(o.min_at, state.tz)}`);
setText('max', `${f1(o.max)}°`);
setText('max-at', `hoogst om ${formatTime(o.max_at, state.tz)}`);
}
function renderRooms(rooms) {
const html = rooms.map((r) => {
const off = !r.reachable || r.temp === null;
return `<div class="a-room${off ? ' off' : ''}">
<p class="a-rname">${esc(r.name)}</p>
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span></p>
</div>`;
});
$('rooms').innerHTML = html.join('');
}
function renderRain(rain, now) {
setText('rain-today', f1(rain.today));
setText('month-name', monthName(now, state.tz));
setText('month-total', `${f1(rain.month_total)} mm`);
const bars = rain.previous_days.map((p) => ({ label: dayAbbr(p.date, state.tz), mm: p.mm }));
bars.push({ label: 'Vandaag', mm: rain.today, now: true });
const heights = barHeights(bars.map((b) => b.mm));
$('bars').innerHTML = bars.map((b, i) => `<div class="a-bar${b.now ? ' now' : ''}">
<span class="num">${f1(b.mm)}</span><i style="height:${heights[i].toFixed(1)}px"></i><span>${b.label}</span>
</div>`).join('');
}
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
<div class="a-icon">${icon(wmoIcon(d.weather_code))}</div>
<p class="a-hl num">${fInt(d.temp_max)}°<span>${fInt(d.temp_min)}°</span></p>
<p class="a-fr num">${fmm(d.precip_mm)}<span>${d.precip_prob === null ? '–' : d.precip_prob + '%'}</span></p>
</div>`).join('');
}
function renderBanner() {
const banner = $('banner');
const errors = state.data ? state.data.errors : [];
if (errors.includes('netatmo_auth')) {
banner.innerHTML = 'Netatmo-koppeling verlopen<small>Voer <code>bin/netatmo-init.php</code> opnieuw uit met een nieuwe refresh token.</small>';
banner.hidden = false;
} else if (!state.data) {
banner.hidden = false;
} else {
banner.hidden = true;
}
}
function renderStatus() {
const el = $('status');
if (state.offlineSince) {
el.textContent = `geen verbinding sinds ${formatTime(state.offlineSince, state.tz)}`;
el.hidden = false;
} else {
el.hidden = true;
}
}
function render() {
const now = new Date();
const d = state.data;
renderHeader(now);
if (d) {
renderMeasurement(now);
renderOutdoor(d.outdoor);
renderRooms(d.indoor);
renderRain(d.rain, now);
renderForecast(d.forecast);
}
renderBanner();
renderStatus();
}
function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
/* ---------- data ---------- */
async function fetchData() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(API_URL, { cache: 'no-store', signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json || !json.station) throw new Error('onvolledig antwoord');
return { json, fromCache: res.headers.get('X-From-Cache') === '1' };
} finally {
clearTimeout(timer);
}
}
let refreshing = false;
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
const { json, fromCache } = await fetchData();
state.data = json;
state.tz = json.timezone || FALLBACK_TZ;
if (fromCache) {
state.offlineSince = state.offlineSince || state.lastOk || new Date();
} else {
state.lastOk = new Date();
state.offlineSince = null;
}
} catch (err) {
console.warn('weerscherm: ophalen mislukt', err);
if (!state.offlineSince) state.offlineSince = state.lastOk || new Date();
if (!state.data) {
$('banner').innerHTML = 'Geen verbinding met de server<small>Er wordt elke 5 minuten opnieuw geprobeerd.</small>';
}
} finally {
refreshing = false;
render();
}
}
/* ---------- timers ---------- */
function scheduleClock() {
const now = new Date();
const wait = 60000 - (now.getSeconds() * 1000 + now.getMilliseconds());
setTimeout(() => {
renderHeader(new Date());
renderMeasurement(new Date());
scheduleClock();
}, wait);
}
function scheduleMidnight() {
setTimeout(() => {
refresh();
scheduleMidnight();
}, msUntil(new Date(), state.tz, 0));
}
function scheduleReload() {
setTimeout(() => location.reload(), msUntil(new Date(), state.tz, RELOAD_HOUR));
}
async function requestWakeLock() {
if (!('wakeLock' in navigator)) return;
try {
await navigator.wakeLock.request('screen');
} catch (err) {
// Niet beschikbaar of geweigerd; Automatisch vergrendelen op "Nooit" is de primaire oplossing.
}
}
/* ---------- start ---------- */
window.addEventListener('resize', fit);
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
fit();
refresh();
requestWakeLock();
}
});
fit();
render();
refresh();
setInterval(refresh, REFRESH_MS);
scheduleClock();
scheduleMidnight();
scheduleReload();
requestWakeLock();