diff --git a/public/app.js b/public/app.js
index 034a30a..5dca169 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,6 +1,7 @@
import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
+ co2Level, fPct, fHpa, seriesRange, sparklinePath,
} from './format.js';
import { icon } from './icons.js';
@@ -57,6 +58,7 @@ function renderMeasurement(now) {
function renderOutdoor(o) {
setText('trend', trendLabel(o.trend));
+ setText('out-hum', o.humidity === null || o.humidity === undefined ? '' : `RV ${fPct(o.humidity)}`);
setText('temp', `${f1(o.temp)}°`);
$('mark').style.left = `${markerPos(o.temp, o.min, o.max).toFixed(1)}%`;
setText('min', `${f1(o.min)}°`);
@@ -68,10 +70,12 @@ function renderOutdoor(o) {
function renderRooms(rooms) {
const html = rooms.map((r) => {
const off = !r.reachable || r.temp === null;
+ const co2 = co2Level(off ? null : r.co2);
+ const pill = `${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}`;
return `
-
${esc(r.name)}
+
${esc(r.name)}${pill}
${off ? '–' : f1(r.temp) + '°'}
-
↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}
+
↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}RV ${off ? '–' : fPct(r.humidity)}
`;
});
$('rooms').innerHTML = html.join('');
@@ -90,6 +94,15 @@ function renderRain(rain, now) {
`).join('');
}
+function renderPressure(p) {
+ setText('press', fHpa(p.current));
+ setText('press-trend', trendLabel(p.trend));
+ const values = p.history.map((h) => h.hpa);
+ const range = seriesRange(values);
+ setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
+ $('press-path').setAttribute('d', sparklinePath(values, 560, 56));
+}
+
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `
${dayAbbr(d.date, state.tz)}
@@ -131,6 +144,7 @@ function render() {
renderOutdoor(d.outdoor);
renderRooms(d.indoor);
renderRain(d.rain, now);
+ renderPressure(d.pressure || { current: null, trend: null, history: [] });
renderForecast(d.forecast);
}
renderBanner();
diff --git a/public/format.js b/public/format.js
index 2d2780f..28fbc1f 100644
--- a/public/format.js
+++ b/public/format.js
@@ -122,3 +122,49 @@ export function msUntil(now, tz, hour, minute = 0) {
if (diff <= 0) diff += 86400;
return Math.max(1000, diff * 1000 - now.getMilliseconds());
}
+
+/** CO₂-niveau volgens de Netatmo-drempels: tot 1000 goed, tot 1500 matig, daarboven slecht. */
+export function co2Level(ppm) {
+ if (!isNum(ppm)) return { level: 'unknown', label: DASH };
+ if (ppm <= 1000) return { level: 'good', label: 'goed' };
+ if (ppm <= 1500) return { level: 'medium', label: 'matig' };
+ return { level: 'bad', label: 'slecht' };
+}
+
+/** 48 -> "48%"; null -> "–" */
+export function fPct(v) {
+ return isNum(v) ? `${Math.round(v)}%` : DASH;
+}
+
+/** 1014.2 -> "1014 hPa"; null -> "–" */
+export function fHpa(v) {
+ return isNum(v) ? `${Math.round(v)} hPa` : DASH;
+}
+
+/** Laagste en hoogste waarde van een reeks (null-waarden genegeerd), of null zonder geldige waarden. */
+export function seriesRange(values) {
+ const nums = values.filter(isNum);
+ if (nums.length === 0) return null;
+ return { min: Math.min(...nums), max: Math.max(...nums) };
+}
+
+/**
+ * SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max
+ * met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "".
+ */
+export function sparklinePath(values, w, h, pad = 3) {
+ const range = seriesRange(values);
+ if (!range || values.length < 2) return '';
+ const span = range.max - range.min;
+ const innerW = w - 2 * pad;
+ const innerH = h - 2 * pad;
+ const step = innerW / (values.length - 1);
+ const parts = [];
+ values.forEach((v, i) => {
+ if (!isNum(v)) return;
+ const x = pad + i * step;
+ const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
+ parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
+ });
+ return parts.join(' ');
+}
diff --git a/public/index.html b/public/index.html
index 1f2c674..150fb52 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,7 +26,7 @@
- Buiten
+ Buiten
–
@@ -35,6 +35,13 @@
–hoogst om –
+
diff --git a/public/styles.css b/public/styles.css
index 7b3cca8..647135f 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -9,6 +9,8 @@
--cold: #3A75A3;
--rain: #2E6A99;
--sun: #D39A2C;
+ --good: #4E8F5A;
+ --bad: #B5433B;
--iconfill: #E8EDF1;
--w: 1133px;
--h: 744px;
@@ -78,21 +80,38 @@ html, body {
.a-hero { align-self: center; }
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
.a-trend { font-size: 17px; font-weight: 400; }
-.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 24px -8px; }
+.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
+.a-hum { font-size: 17px; font-weight: 400; }
+.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
.a-range { width: 560px; }
.a-track { position: relative; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--cold), var(--warm)); }
.a-mark { position: absolute; top: 50%; width: 28px; height: 28px; border-radius: 50%; background: var(--bg); border: 5px solid var(--ink); transform: translate(-50%, -50%); transition: left .6s ease; }
.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); }
.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; }
+/* Luchtdruk, 24 uur */
+.a-pressure { width: 560px; margin-top: 18px; }
+.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
+.a-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
+.a-prange { margin-left: auto; font-size: 14px; }
+.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
+.a-spark-base { stroke: var(--line); stroke-width: 1; }
+.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
+
/* Binnen */
.a-inside { align-self: center; }
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
.a-room:first-child { border-top: 0; }
-.a-rname { font-size: 19px; color: var(--muted); }
+.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
+.pill { display: inline-block; font-size: 13px; font-weight: 600; line-height: 1; padding: 5px 10px; border-radius: 999px; color: #fff; background: var(--muted); letter-spacing: .01em; white-space: nowrap; }
+.pill.good { background: var(--good); }
+.pill.medium { background: var(--warm); }
+.pill.bad { background: var(--bad); }
+.pill.unknown { background: var(--line); color: var(--muted); }
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; }
.a-room.off .a-rtemp { color: var(--muted); }
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
+.a-rmm .rv { color: var(--muted); }
/* Onderste strook */
.a-bottom { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 22px; display: grid; grid-template-columns: 390px 1fr; column-gap: 36px; }
diff --git a/public/sw.js b/public/sw.js
index 952e016..51328f5 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
-const VERSION = 'weerscherm-v1';
+const VERSION = 'weerscherm-v2';
const SHELL = [
'./',
'./index.html',
diff --git a/tests/js/format.test.js b/tests/js/format.test.js
index 4addfe7..6228163 100644
--- a/tests/js/format.test.js
+++ b/tests/js/format.test.js
@@ -118,3 +118,36 @@ test('msUntil computes the wait until the next local time', () => {
assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500);
assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer');
});
+
+import { co2Level, fPct, fHpa, seriesRange, sparklinePath } from '../../public/format.js';
+
+test('co2Level uses the Netatmo thresholds', () => {
+ assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
+ assert.deepEqual(co2Level(1000), { level: 'good', label: 'goed' });
+ assert.deepEqual(co2Level(1001), { level: 'medium', label: 'matig' });
+ assert.deepEqual(co2Level(1500), { level: 'medium', label: 'matig' });
+ assert.deepEqual(co2Level(1501), { level: 'bad', label: 'slecht' });
+ assert.deepEqual(co2Level(null), { level: 'unknown', label: '–' });
+});
+
+test('fPct and fHpa', () => {
+ assert.equal(fPct(48), '48%');
+ assert.equal(fPct(47.6), '48%');
+ assert.equal(fPct(null), '–');
+ assert.equal(fHpa(1014.2), '1014 hPa');
+ assert.equal(fHpa(null), '–');
+});
+
+test('seriesRange ignores nulls', () => {
+ assert.deepEqual(seriesRange([1010, null, 1015.5, 1008]), { min: 1008, max: 1015.5 });
+ assert.equal(seriesRange([null, undefined]), null);
+});
+
+test('sparklinePath scales points into the box', () => {
+ assert.equal(sparklinePath([1000, 1010, 1005], 100, 50, 0), 'M0.0,50.0 L50.0,0.0 L100.0,25.0');
+ assert.equal(sparklinePath([1000, 1010], 100, 50, 5), 'M5.0,45.0 L95.0,5.0');
+ assert.equal(sparklinePath([1010, 1010, 1010], 100, 50, 0), 'M0.0,25.0 L50.0,25.0 L100.0,25.0', 'vlakke reeks in het midden');
+ assert.equal(sparklinePath([1000, null, 1010], 100, 50, 0), 'M0.0,50.0 L100.0,0.0', 'null-punt overgeslagen');
+ assert.equal(sparklinePath([1000], 100, 50), '');
+ assert.equal(sparklinePath([], 100, 50), '');
+});