docs: add spec, mockup and implementation plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
config.php
|
||||
/storage/
|
||||
/vendor/
|
||||
/node_modules/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,416 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Weerscherm: drie layoutvoorstellen</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; background: #D8DDE1; color: #1B2A35; padding: 24px 16px 40px; }
|
||||
.wrap { max-width: 1161px; margin: 0 auto; }
|
||||
.intro h1 { font-size: 22px; font-weight: 600; }
|
||||
.intro p { font-size: 15px; color: #4F5F6B; margin-top: 4px; max-width: 72ch; line-height: 1.45; }
|
||||
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0 14px; }
|
||||
.tabs button { font: inherit; font-size: 15px; padding: 9px 16px; border-radius: 999px; border: 1px solid #A9B5BE; background: transparent; color: #1B2A35; cursor: pointer; }
|
||||
.tabs button[aria-selected="true"] { background: #1B2A35; color: #fff; border-color: #1B2A35; }
|
||||
.tabs button:focus-visible { outline: 3px solid #3A75A3; outline-offset: 2px; }
|
||||
.scaler { position: relative; }
|
||||
.device { position: absolute; top: 0; left: 0; width: 1161px; height: 772px; padding: 14px; background: #23272B; border-radius: 34px; transform-origin: top left; }
|
||||
.screen { width: 1133px; height: 744px; border-radius: 20px; overflow: hidden; position: relative; }
|
||||
.design { position: absolute; inset: 0; display: none; }
|
||||
.design.active { display: grid; }
|
||||
.caption { font-size: 15px; color: #4F5F6B; margin-top: 14px; max-width: 72ch; line-height: 1.45; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.ic, .icn { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.ic { width: 100%; height: 100%; display: block; }
|
||||
.i-sun { stroke: var(--sun); }
|
||||
.i-drop { stroke: var(--rain); }
|
||||
.i-cloud { fill: var(--iconfill); }
|
||||
.cold { color: var(--cold); }
|
||||
.warm { color: var(--warm); }
|
||||
|
||||
/* ---------- Ontwerp A: buiten centraal ---------- */
|
||||
.da { --bg:#E8EDF1; --ink:#1B2A35; --muted:#5F707C; --line:#C6D0D8; --warm:#B8761C; --cold:#3A75A3; --rain:#2E6A99; --sun:#D39A2C; --iconfill:#E8EDF1;
|
||||
background: var(--bg); color: var(--ink); font-family: -apple-system, "SF Pro Display", system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 30px 44px; grid-template-columns: 1fr 330px; grid-template-rows: 40px 1fr 232px; column-gap: 56px; }
|
||||
.a-top { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.a-date { font-size: 22px; font-weight: 500; }
|
||||
.a-right { display: flex; gap: 18px; align-items: baseline; }
|
||||
.a-upd { font-size: 15px; color: var(--muted); }
|
||||
.a-clock { font-size: 26px; font-weight: 300; }
|
||||
.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-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%); }
|
||||
.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; }
|
||||
.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-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; }
|
||||
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
|
||||
.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; }
|
||||
.a-sub { font-size: 19px; color: var(--muted); font-weight: 500; margin-bottom: 10px; }
|
||||
.a-rainrow { display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.a-rbig { font-size: 22px; color: var(--rain); }
|
||||
.a-rbig span { font-size: 60px; font-weight: 300; line-height: 1; }
|
||||
.a-rsm { font-size: 17px; color: var(--muted); }
|
||||
.a-month { font-size: 17px; margin-top: 18px; color: var(--muted); white-space: nowrap; }
|
||||
.a-month b { color: var(--ink); font-weight: 600; font-size: 20px; margin-left: 4px; }
|
||||
.a-bars { display: flex; gap: 6px; align-items: flex-end; }
|
||||
.a-bar { display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 14px; color: var(--muted); min-width: 38px; }
|
||||
.a-bar i { display: block; width: 24px; border-radius: 4px 4px 0 0; background: var(--rain); opacity: .4; }
|
||||
.a-bar.now i { opacity: 1; }
|
||||
.a-bar.now span { color: var(--ink); font-weight: 600; }
|
||||
.a-fc { display: grid; grid-template-columns: repeat(5, 1fr); text-align: center; }
|
||||
.a-day { display: flex; flex-direction: column; align-items: center; gap: 6px; border-left: 1px solid var(--line); }
|
||||
.a-day:first-child { border-left: 0; }
|
||||
.a-dname { font-size: 19px; font-weight: 600; }
|
||||
.a-icon { width: 58px; height: 58px; }
|
||||
.a-hl { font-size: 28px; font-weight: 500; }
|
||||
.a-hl span { font-size: 22px; color: var(--muted); font-weight: 400; margin-left: 6px; }
|
||||
.a-fr { font-size: 16px; color: var(--rain); }
|
||||
.a-fr span { color: var(--muted); margin-left: 6px; }
|
||||
|
||||
/* ---------- Ontwerp B: grafisch ---------- */
|
||||
.db { --bg:#EFF1EC; --panel:#FFFFFF; --ink:#1E2B24; --muted:#66726A; --line:#DCE0D7; --warm:#C47A22; --cold:#44799F; --rain:#386CA4; --rainsoft:#BFD0E3; --sun:#D2952A; --iconfill:#FFFFFF;
|
||||
background: var(--bg); color: var(--ink); font-family: ui-rounded, "SF Pro Rounded", -apple-system, system-ui, sans-serif;
|
||||
padding: 26px 32px; grid-template-columns: 1.35fr 1fr 1fr 1fr; grid-template-rows: 34px 232px 1fr; gap: 18px; }
|
||||
.b-top { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; font-size: 20px; }
|
||||
.b-top span:first-child { font-weight: 600; }
|
||||
.b-top span:last-child { color: var(--muted); font-size: 17px; }
|
||||
.b-name { font-size: 18px; color: var(--muted); font-weight: 500; }
|
||||
.b-out { background: var(--ink); color: #F1F3EE; border-radius: 26px; padding: 22px 26px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.b-out .b-name { color: #B9C4BC; }
|
||||
.b-out .b-t { font-size: 104px; font-weight: 600; line-height: 1; letter-spacing: -.03em; }
|
||||
.b-out .cold { color: #9EC4E3; }
|
||||
.b-out .warm { color: #F1BC6C; }
|
||||
.b-mm { font-size: 16px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.b-in { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 20px 22px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.b-in .b-t { font-size: 62px; font-weight: 600; line-height: 1; letter-spacing: -.02em; }
|
||||
.b-in .b-mm { flex-direction: row; gap: 14px; }
|
||||
.b-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 18px 24px; }
|
||||
.b-fc { grid-column: 1 / span 2; }
|
||||
.b-rain { grid-column: 3 / span 2; }
|
||||
.b-h { font-size: 17px; color: var(--muted); font-weight: 500; margin-bottom: 6px; }
|
||||
.b-fc > svg, .b-rain > svg { width: 100%; height: auto; display: block; }
|
||||
.b-dn { font-size: 17px; font-weight: 600; fill: var(--ink); }
|
||||
.b-hi { font-size: 19px; font-weight: 600; fill: var(--ink); }
|
||||
.b-lo { font-size: 16px; fill: var(--muted); }
|
||||
.b-mmt { font-size: 14px; font-weight: 600; fill: var(--rain); }
|
||||
.b-pct { font-size: 13px; fill: var(--muted); }
|
||||
.b-total { font-size: 46px; font-weight: 600; line-height: 1.1; }
|
||||
.b-total span { font-size: 20px; color: var(--muted); font-weight: 500; margin-left: 6px; }
|
||||
.b-rain > svg { margin: 10px 0 14px; }
|
||||
.b-dl { font-size: 12px; fill: var(--muted); }
|
||||
.b-dl.now { fill: var(--ink); font-weight: 700; }
|
||||
.b-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.b-stats p { font-size: 14px; color: var(--muted); }
|
||||
.b-stats b { display: block; font-size: 22px; color: var(--ink); font-weight: 600; }
|
||||
.b-stats .now b { color: var(--rain); }
|
||||
|
||||
/* ---------- Ontwerp C: avondscherm ---------- */
|
||||
.dc { --bg:#15212F; --ink:#ECE5D8; --muted:#8D9AA9; --line:#2B3B4E; --warm:#E2B46F; --cold:#95B9DB; --rain:#88AFDC; --sun:#E2B46F; --iconfill:#15212F;
|
||||
background: var(--bg); color: var(--ink); font-family: -apple-system, system-ui, "Segoe UI", sans-serif;
|
||||
padding: 34px 48px; grid-template-columns: 1fr 410px; grid-template-rows: 40px 1fr auto; column-gap: 72px; }
|
||||
.serif { font-family: ui-serif, "New York", "Iowan Old Style", Georgia, serif; }
|
||||
.c-date { font-size: 20px; color: var(--muted); align-self: center; }
|
||||
.c-clock { font-size: 30px; text-align: right; align-self: center; }
|
||||
.c-hero { align-self: center; }
|
||||
.c-label { font-size: 20px; color: var(--muted); }
|
||||
.c-temp { font-size: 196px; line-height: 1; font-weight: 400; letter-spacing: -.02em; margin: 2px 0 10px -6px; }
|
||||
.c-mm { font-size: 26px; display: flex; gap: 32px; }
|
||||
.c-mm small { font-family: -apple-system, system-ui, sans-serif; font-size: 15px; color: var(--muted); margin-left: 6px; }
|
||||
.c-inside { grid-column: 1; grid-row: 3; display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); padding-top: 20px; }
|
||||
.c-rname { font-size: 17px; color: var(--muted); }
|
||||
.c-rt { font-size: 46px; line-height: 1.15; }
|
||||
.c-rmm { font-size: 15px; color: var(--muted); }
|
||||
.c-right { grid-column: 2; grid-row: 2 / 4; margin-top: 22px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.c-row { display: grid; grid-template-columns: 104px 40px 1fr auto; align-items: center; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
.c-row:first-child { border-top: 1px solid var(--line); }
|
||||
.c-dn { font-size: 19px; }
|
||||
.c-ic { width: 40px; height: 40px; }
|
||||
.c-hl { font-size: 26px; }
|
||||
.c-hl span { color: var(--muted); font-size: 22px; margin-left: 8px; }
|
||||
.c-fr { font-size: 15px; color: var(--rain); text-align: right; line-height: 1.3; }
|
||||
.c-fr span { display: block; color: var(--muted); }
|
||||
.c-rain { border-top: 1px solid var(--line); padding-top: 18px; }
|
||||
.c-rl { font-size: 17px; color: var(--muted); }
|
||||
.c-rbig { font-size: 17px; color: var(--muted); margin: 2px 0 10px; }
|
||||
.c-rbig b { font-size: 44px; font-weight: 400; color: var(--rain); margin-right: 6px; }
|
||||
.c-past { display: flex; gap: 24px; font-size: 16px; color: var(--muted); }
|
||||
.c-past b { color: var(--ink); font-weight: 400; margin-left: 4px; }
|
||||
.c-month { font-size: 16px; color: var(--muted); margin-top: 8px; }
|
||||
.c-month b { color: var(--ink); font-weight: 400; margin-left: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="intro">
|
||||
<h1>Weerscherm voor de iPad mini: drie layouts</h1>
|
||||
<p>Liggend formaat, op ware grootte van een iPad mini (1133 × 744 punten). Alle waarden zijn voorbeelddata; de namen van de binnenruimtes zijn placeholders.</p>
|
||||
</div>
|
||||
<div class="tabs" role="tablist" aria-label="Kies een ontwerp">
|
||||
<button role="tab" aria-selected="true" data-d="a">A: Buiten centraal</button>
|
||||
<button role="tab" aria-selected="false" data-d="b">B: Grafisch</button>
|
||||
<button role="tab" aria-selected="false" data-d="c">C: Avondscherm</button>
|
||||
</div>
|
||||
<div class="scaler"><div class="device"><div class="screen" id="screen"></div></div></div>
|
||||
<p class="caption" id="caption"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const D = {
|
||||
datum: 'Vrijdag 11 september', tijd: '14:32', meting: '14:30',
|
||||
buiten: { t: 16.4, min: 9.2, minT: '06:10', max: 19.8, maxT: '13:05', trend: '↘ dalend' },
|
||||
binnen: [
|
||||
{ naam: 'Woonkamer', t: 21.3, min: 20.4, max: 22.1 },
|
||||
{ naam: 'Slaapkamer', t: 18.9, min: 18.1, max: 19.6 },
|
||||
{ naam: 'Kantoor', t: 20.2, min: 19.0, max: 21.0 }
|
||||
],
|
||||
regen: {
|
||||
vandaag: 2.4,
|
||||
dagen: [ { d: 'di', mm: 1.2 }, { d: 'wo', mm: 6.8 }, { d: 'do', mm: 0.0 } ],
|
||||
maand: 38.5, maandNaam: 'september',
|
||||
perDag: [4.3, 0, 3.2, 5.1, 0, 0, 15.5, 1.2, 6.8, 0, 2.4],
|
||||
vandaagNr: 11, dagenInMaand: 30
|
||||
},
|
||||
verwachting: [
|
||||
{ d: 'za', dl: 'zaterdag', icon: 'partly', hi: 18, lo: 10, mm: 0.4, kans: 20 },
|
||||
{ d: 'zo', dl: 'zondag', icon: 'sun', hi: 20, lo: 11, mm: 0, kans: 5 },
|
||||
{ d: 'ma', dl: 'maandag', icon: 'rain', hi: 17, lo: 12, mm: 8.2, kans: 80 },
|
||||
{ d: 'di', dl: 'dinsdag', icon: 'showers', hi: 15, lo: 9, mm: 3.1, kans: 60 },
|
||||
{ d: 'wo', dl: 'woensdag', icon: 'partly', hi: 16, lo: 8, mm: 0.2, kans: 15 }
|
||||
]
|
||||
};
|
||||
|
||||
const f1 = v => v.toFixed(1).replace('.', ',');
|
||||
const fmm = v => v === 0 ? '0 mm' : f1(v) + ' mm';
|
||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
function iconInner(name) {
|
||||
return {
|
||||
sun: '<g class="i-sun"><circle cx="24" cy="24" r="8"/><path d="M24 6v5M24 37v5M6 24h5M37 24h5M11.3 11.3l3.5 3.5M33.2 33.2l3.5 3.5M11.3 36.7l3.5-3.5M33.2 14.8l3.5-3.5"/></g>',
|
||||
partly: '<g class="i-sun"><circle cx="18" cy="17" r="6"/><path d="M18 5v3M6 17h3M9.5 8.5l2 2M26.5 8.5l-2 2"/></g><path class="i-cloud" d="M17 40h18a7 7 0 0 0 .8-13.96A10 10 0 0 0 17.3 28 6 6 0 0 0 17 40z"/>',
|
||||
rain: '<path class="i-cloud" d="M15 30h19a7 7 0 0 0 .8-13.96A10 10 0 0 0 15.3 18 6 6 0 0 0 15 30z"/><path class="i-drop" d="M18 35l-2 5M26 35l-2 5M34 35l-2 5"/>',
|
||||
showers: '<g class="i-sun"><circle cx="16" cy="14" r="5"/><path d="M16 4v2.5M6 14h2.5M9 7l1.8 1.8M23 7l-1.8 1.8"/></g><path class="i-cloud" d="M17 32h17a6.5 6.5 0 0 0 .7-12.96A9.5 9.5 0 0 0 17.3 21 5.5 5.5 0 0 0 17 32z"/><path class="i-drop" d="M22 37l-2 5M30 37l-2 5"/>'
|
||||
}[name];
|
||||
}
|
||||
const icon = name => `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">${iconInner(name)}</svg>`;
|
||||
|
||||
/* ---------- A ---------- */
|
||||
function designA() {
|
||||
const b = D.buiten;
|
||||
const pos = ((b.t - b.min) / (b.max - b.min) * 100).toFixed(1);
|
||||
const bars = [...D.regen.dagen.map(x => ({ l: cap(x.d), mm: x.mm })), { l: 'Vandaag', mm: D.regen.vandaag, now: true }];
|
||||
const maxR = Math.max(...bars.map(x => x.mm));
|
||||
return `
|
||||
<header class="a-top">
|
||||
<span class="a-date">${D.datum}</span>
|
||||
<span class="a-right"><span class="a-upd">meting ${D.meting}</span><span class="a-clock num">${D.tijd}</span></span>
|
||||
</header>
|
||||
<section class="a-hero">
|
||||
<p class="a-label">Buiten <span class="a-trend">${b.trend}</span></p>
|
||||
<p class="a-temp num">${f1(b.t)}°</p>
|
||||
<div class="a-range">
|
||||
<div class="a-track"><span class="a-mark" style="left:${pos}%"></span></div>
|
||||
<div class="a-ends num">
|
||||
<span><b class="cold">${f1(b.min)}°</b>laagst om ${b.minT}</span>
|
||||
<span><b class="warm">${f1(b.max)}°</b>hoogst om ${b.maxT}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="a-inside">
|
||||
${D.binnen.map(r => `
|
||||
<div class="a-room">
|
||||
<p class="a-rname">${r.naam}</p>
|
||||
<p class="a-rtemp num">${f1(r.t)}°</p>
|
||||
<p class="a-rmm num"><span class="cold">↓ ${f1(r.min)}°</span><span class="warm">↑ ${f1(r.max)}°</span></p>
|
||||
</div>`).join('')}
|
||||
</section>
|
||||
<section class="a-bottom">
|
||||
<div>
|
||||
<p class="a-sub">Regen</p>
|
||||
<div class="a-rainrow">
|
||||
<div>
|
||||
<p class="a-rbig"><span class="num">${f1(D.regen.vandaag)}</span> mm</p>
|
||||
<p class="a-rsm">vandaag</p>
|
||||
<p class="a-month">${cap(D.regen.maandNaam)}<b class="num">${f1(D.regen.maand)} mm</b></p>
|
||||
</div>
|
||||
<div class="a-bars">
|
||||
${bars.map(x => `<div class="a-bar${x.now ? ' now' : ''}"><span class="num">${f1(x.mm)}</span><i style="height:${Math.max(2, x.mm / maxR * 70)}px"></i><span>${x.l}</span></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="a-fc">
|
||||
${D.verwachting.map(d => `
|
||||
<div class="a-day">
|
||||
<p class="a-dname">${cap(d.d)}</p>
|
||||
<div class="a-icon">${icon(d.icon)}</div>
|
||||
<p class="a-hl num">${d.hi}°<span>${d.lo}°</span></p>
|
||||
<p class="a-fr num">${fmm(d.mm)}<span>${d.kans}%</span></p>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ---------- B ---------- */
|
||||
function forecastChart() {
|
||||
const W = 518, cw = W / 5;
|
||||
const y = t => 210 - (t - 6) / 16 * 110;
|
||||
let s = `<svg viewBox="0 0 518 310" role="img" aria-label="Verwachting komende vijf dagen">
|
||||
<defs><linearGradient id="tg" gradientUnits="userSpaceOnUse" x1="0" y1="100" x2="0" y2="210">
|
||||
<stop offset="0" style="stop-color:var(--warm)"/><stop offset="1" style="stop-color:var(--cold)"/>
|
||||
</linearGradient></defs>
|
||||
<line x1="8" x2="510" y1="285.5" y2="285.5" style="stroke:var(--line)"/>`;
|
||||
D.verwachting.forEach((d, i) => {
|
||||
const cx = cw * i + cw / 2, yh = y(d.hi), yl = y(d.lo);
|
||||
const rh = d.mm > 0 ? Math.max(2, d.mm / 10 * 40) : 0;
|
||||
s += `
|
||||
<text x="${cx}" y="20" text-anchor="middle" class="b-dn">${cap(d.d)}</text>
|
||||
<svg x="${cx - 22}" y="30" width="44" height="44" viewBox="0 0 48 48" class="icn">${iconInner(d.icon)}</svg>
|
||||
<text x="${cx}" y="${yh - 10}" text-anchor="middle" class="b-hi">${d.hi}°</text>
|
||||
<rect x="${cx - 9}" y="${yh}" width="18" height="${yl - yh}" rx="9" style="fill:url(#tg)"/>
|
||||
<text x="${cx}" y="${yl + 22}" text-anchor="middle" class="b-lo">${d.lo}°</text>
|
||||
${rh ? `<rect x="${cx - 16}" y="${285 - rh}" width="32" height="${rh}" rx="3" style="fill:var(--rain)"/>` : ''}
|
||||
<text x="${cx}" y="${285 - rh - 7}" text-anchor="middle" class="b-mmt">${fmm(d.mm)}</text>
|
||||
<text x="${cx}" y="304" text-anchor="middle" class="b-pct">${d.kans}% kans</text>`;
|
||||
});
|
||||
return s + '</svg>';
|
||||
}
|
||||
|
||||
function monthStrip() {
|
||||
const R = D.regen, W = 436, cw = W / R.dagenInMaand, base = 112;
|
||||
const maxMM = Math.max(...R.perDag);
|
||||
let s = `<svg viewBox="0 0 436 140" role="img" aria-label="Regen per dag in ${R.maandNaam}">
|
||||
<line x1="0" x2="436" y1="${base + .5}" y2="${base + .5}" style="stroke:var(--line)"/>`;
|
||||
for (let i = 0; i < R.dagenInMaand; i++) {
|
||||
const day = i + 1, cx = cw * i + cw / 2;
|
||||
if (day <= R.vandaagNr) {
|
||||
const mm = R.perDag[i];
|
||||
const color = day === R.vandaagNr ? 'var(--rain)' : day >= R.vandaagNr - 3 ? 'var(--rain)' : 'var(--rainsoft)';
|
||||
const op = day === R.vandaagNr ? 1 : day >= R.vandaagNr - 3 ? .55 : 1;
|
||||
if (mm > 0) {
|
||||
const h = Math.max(3, mm / maxMM * 90);
|
||||
s += `<rect x="${cx - 4.5}" y="${base - h}" width="9" height="${h}" rx="2" style="fill:${color};opacity:${op}"/>`;
|
||||
} else {
|
||||
s += `<rect x="${cx - 4.5}" y="${base - 2}" width="9" height="2" style="fill:var(--muted);opacity:.4"/>`;
|
||||
}
|
||||
} else {
|
||||
s += `<circle cx="${cx}" cy="${base - 3}" r="1.8" style="fill:var(--line)"/>`;
|
||||
}
|
||||
if ([1, 5, 11, 15, 20, 25, 30].includes(day)) {
|
||||
s += `<text x="${cx}" y="${base + 20}" text-anchor="middle" class="b-dl${day === R.vandaagNr ? ' now' : ''}">${day}</text>`;
|
||||
}
|
||||
}
|
||||
return s + '</svg>';
|
||||
}
|
||||
|
||||
function designB() {
|
||||
const b = D.buiten, R = D.regen;
|
||||
return `
|
||||
<header class="b-top"><span>${D.datum}</span><span class="num">${D.tijd}, meting ${D.meting}</span></header>
|
||||
<section class="b-out">
|
||||
<p class="b-name">Buiten</p>
|
||||
<p class="b-t num">${f1(b.t)}°</p>
|
||||
<p class="b-mm num"><span class="cold">↓ ${f1(b.min)}° om ${b.minT}</span><span class="warm">↑ ${f1(b.max)}° om ${b.maxT}</span></p>
|
||||
</section>
|
||||
${D.binnen.map(r => `
|
||||
<section class="b-in">
|
||||
<p class="b-name">${r.naam}</p>
|
||||
<p class="b-t num">${f1(r.t)}°</p>
|
||||
<p class="b-mm num"><span class="cold">↓ ${f1(r.min)}°</span><span class="warm">↑ ${f1(r.max)}°</span></p>
|
||||
</section>`).join('')}
|
||||
<section class="b-panel b-fc">
|
||||
<p class="b-h">Komende 5 dagen</p>
|
||||
${forecastChart()}
|
||||
</section>
|
||||
<section class="b-panel b-rain">
|
||||
<p class="b-h">Regen in ${R.maandNaam}</p>
|
||||
<p class="b-total num">${f1(R.maand)}<span>mm deze maand</span></p>
|
||||
${monthStrip()}
|
||||
<div class="b-stats num">
|
||||
<p class="now">Vandaag<b>${fmm(R.vandaag)}</b></p>
|
||||
${[...R.dagen].reverse().map(x => `<p>${cap(x.d)}<b>${fmm(x.mm)}</b></p>`).join('')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ---------- C ---------- */
|
||||
function designC() {
|
||||
const b = D.buiten, R = D.regen;
|
||||
return `
|
||||
<p class="c-date">${D.datum}</p>
|
||||
<p class="c-clock serif num">${D.tijd}</p>
|
||||
<section class="c-hero">
|
||||
<p class="c-label">Buiten</p>
|
||||
<p class="c-temp serif num">${f1(b.t)}°</p>
|
||||
<p class="c-mm serif num">
|
||||
<span class="cold">↓ ${f1(b.min)}°<small>${b.minT}</small></span>
|
||||
<span class="warm">↑ ${f1(b.max)}°<small>${b.maxT}</small></span>
|
||||
</p>
|
||||
</section>
|
||||
<section class="c-inside">
|
||||
${D.binnen.map(r => `
|
||||
<div>
|
||||
<p class="c-rname">${r.naam}</p>
|
||||
<p class="c-rt serif num">${f1(r.t)}°</p>
|
||||
<p class="c-rmm num">↓ ${f1(r.min)}° ↑ ${f1(r.max)}°</p>
|
||||
</div>`).join('')}
|
||||
</section>
|
||||
<section class="c-right">
|
||||
<div>
|
||||
${D.verwachting.map(d => `
|
||||
<div class="c-row">
|
||||
<span class="c-dn">${cap(d.dl)}</span>
|
||||
<span class="c-ic">${icon(d.icon)}</span>
|
||||
<span class="c-hl serif num">${d.hi}°<span>${d.lo}°</span></span>
|
||||
<span class="c-fr num">${fmm(d.mm)}<span>${d.kans}%</span></span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="c-rain">
|
||||
<p class="c-rl">Regen</p>
|
||||
<p class="c-rbig"><b class="serif num">${f1(R.vandaag)}</b>mm vandaag</p>
|
||||
<p class="c-past num">${[...R.dagen].reverse().map(x => `<span>${x.d}<b>${f1(x.mm)}</b></span>`).join('')}</p>
|
||||
<p class="c-month num">${cap(R.maandNaam)}<b>${f1(R.maand)} mm</b></p>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ---------- render, tabs, schaal ---------- */
|
||||
const captions = {
|
||||
a: 'A: Buiten centraal. De buitentemperatuur is het grootst en vanaf de bank leesbaar. De balk eronder laat zien waar de huidige temperatuur zit tussen het minimum en maximum van vandaag. Binnenruimtes rechts, regen en verwachting in een rustige strook onderaan.',
|
||||
b: 'B: Grafisch. Buiten en de drie ruimtes als vier gelijkwaardige tegels bovenaan. De verwachting staat als temperatuurbalken op één schaal, zodat je de trend ziet zonder cijfers te lezen. Regen als maandoverzicht per dag, met vandaag en de afgelopen drie dagen uitgelicht.',
|
||||
c: 'C: Avondscherm. Donker en rustig, bedoeld om \u2019s avonds gedimd aan te laten staan in de woonkamer. Alleen cijfers en een lijst, geen grafieken. Kan ook als automatische nachtmodus naast A of B.'
|
||||
};
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
screen.innerHTML = `
|
||||
<div class="design da active" data-d="a">${designA()}</div>
|
||||
<div class="design db" data-d="b">${designB()}</div>
|
||||
<div class="design dc" data-d="c">${designC()}</div>`;
|
||||
|
||||
const caption = document.getElementById('caption');
|
||||
function show(key) {
|
||||
document.querySelectorAll('.design').forEach(el => el.classList.toggle('active', el.dataset.d === key));
|
||||
document.querySelectorAll('.tabs button').forEach(b => b.setAttribute('aria-selected', b.dataset.d === key));
|
||||
caption.textContent = captions[key];
|
||||
}
|
||||
document.querySelectorAll('.tabs button').forEach(b => b.addEventListener('click', () => show(b.dataset.d)));
|
||||
show('a');
|
||||
|
||||
const device = document.querySelector('.device'), scaler = document.querySelector('.scaler');
|
||||
function fit() {
|
||||
const s = Math.min(1, scaler.clientWidth / 1161);
|
||||
device.style.transform = `scale(${s})`;
|
||||
scaler.style.height = (772 * s) + 'px';
|
||||
}
|
||||
window.addEventListener('resize', fit);
|
||||
fit();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,382 @@
|
||||
# Weerscherm Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Een PWA voor een iPad mini (liggend) die Netatmo-stationsdata en een 5-daagse Open-Meteo-verwachting toont, gevoed door een PHP-backend met cache en tokenbeheer.
|
||||
|
||||
**Architecture:** Een PHP 8.3-backend zonder framework serveert `GET /api/data.php` als één JSON-payload. `DashboardService` combineert drie bronnen (stationsdata, regenmetingen, verwachting) via een `WeatherSource`-interface met een `Live`- en een `Fixture`-implementatie, cachet per bron en valt bij storing terug op stale cache. Pure mappers (`StationMapper`, `RainCalculator`, `ForecastMapper`) zijn los testbaar. De frontend is vanilla ES-modules op een vast canvas van 1133×744 dat geschaald wordt; pure formatteerfuncties staan in `format.js` en worden met `node --test` getest.
|
||||
|
||||
**Tech Stack:** PHP 8.3 (`ext-curl`, `ext-json`), PHPUnit 11 (dev), vanilla HTML/CSS/JS (ES-modules), Node 24 `node:test` (dev), service worker, webmanifest.
|
||||
|
||||
**Spec:** `docs/weerscherm-spec.md` (mockup: `docs/design/weerscherm-layouts.html`, ontwerp A).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- PHP 8.3, geen framework, geen runtime-dependencies; alleen `ext-curl` en `ext-json`. PHPUnit als dev-dependency.
|
||||
- Frontend zonder build-stap, externe fonts of CDN. Alles lokaal geserveerd.
|
||||
- Browserdoel iPadOS 15+: moderne JS/CSS en ES-modules zijn toegestaan.
|
||||
- Canvas 1133 × 744, geschaald met `transform: scale(min(innerWidth/1133, innerHeight/744))`, gecentreerd, alleen liggend.
|
||||
- Design tokens: `--bg #E8EDF1`, `--ink #1B2A35`, `--muted #5F707C`, `--line #C6D0D8`, `--cold #3A75A3`, `--warm #B8761C`, `--rain #2E6A99`, `--sun #D39A2C`.
|
||||
- Notatie `nl-NL`, decimale komma, tijden in de stationstijdzone, overal `font-variant-numeric: tabular-nums`.
|
||||
- API: ruwe getallen, ISO 8601 met offset, `null` voor onbekend, `Cache-Control: no-store`, `X-Robots-Tag: noindex`, HTTP 200 zolang er iets te tonen is.
|
||||
- Cache-TTL's: stationsdata 300 s, getmeasure 900 s, forecast 3600 s.
|
||||
- Secrets alleen in `config.php` en `storage/`; beide in `.gitignore`, `storage/` buiten de docroot, tokenbestand `chmod 600`.
|
||||
- Projectroot is deze map (`home-display`), docroot is `public/`.
|
||||
- Commits klein en per taak; `config.php` en `storage/` nooit committen.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
config.example.php # voorbeeldconfig (in git); config.php (niet in git) heeft dezelfde vorm
|
||||
composer.json # autoload psr-4 "Weerscherm\\" => src/, phpunit dev, scripts test
|
||||
phpunit.xml
|
||||
package.json # alleen "test": "node --test tests/js"
|
||||
.gitignore # config.php, storage/, vendor/, node_modules/
|
||||
public/
|
||||
index.html # app-shell, iOS-meta, manifest-link, module script
|
||||
styles.css # ontwerp A, tokens, canvas-schaal, portret-melding, foutstaten
|
||||
app.js # ophalen, renderen, timers, foutafhandeling, SW-registratie
|
||||
format.js # pure functies (formatteren, marker, staven, WMO, datums)
|
||||
icons.js # icon(name) => SVG-string, 9 iconen
|
||||
sw.js # app-shell cache-first, API network-first met timeout
|
||||
manifest.webmanifest
|
||||
robots.txt
|
||||
icons/icon-180.png, icon-192.png, icon-512.png, icon.svg
|
||||
api/data.php # bootstrap + JSON
|
||||
src/
|
||||
Config.php # laadt config.php, defaults
|
||||
Http.php # HttpClient-interface + curl-implementatie + HttpResponse
|
||||
Cache.php # bestandscache per key, met ttl en stale-lezen
|
||||
TokenStore.php # atomisch lezen/schrijven, flock
|
||||
NetatmoClient.php # oauth refresh, getstationsdata, getmeasure
|
||||
OpenMeteoClient.php # daily forecast
|
||||
WeatherSource.php # interface + LiveSource + FixtureSource
|
||||
StationMapper.php # getstationsdata-body -> station/outdoor/indoor
|
||||
RainCalculator.php # getmeasure-body -> today/previous_days/month_total
|
||||
ForecastMapper.php # open-meteo daily -> 5 dagen
|
||||
DashboardService.php # orkestratie, cache, stale-fallback, errors
|
||||
Exceptions.php # UpstreamException, NetatmoAuthException
|
||||
bin/netatmo-init.php # slaat refresh token op in storage/netatmo-token.json
|
||||
tests/
|
||||
bootstrap.php
|
||||
fixtures/getstationsdata.json, getmeasure.json, getmeasure-dst.json, forecast.json
|
||||
StationMapperTest.php, RainCalculatorTest.php, ForecastMapperTest.php
|
||||
TokenStoreTest.php, NetatmoClientTest.php, DashboardServiceTest.php, CacheTest.php
|
||||
js/format.test.js
|
||||
docs/weerscherm-spec.md, docs/design/weerscherm-layouts.html
|
||||
README.md
|
||||
```
|
||||
|
||||
## Interfaces (gedeeld door alle taken)
|
||||
|
||||
```php
|
||||
namespace Weerscherm;
|
||||
|
||||
interface HttpClient {
|
||||
/** @param array<string,string|int|float> $query */
|
||||
public function get(string $url, array $query = [], array $headers = []): HttpResponse;
|
||||
public function postForm(string $url, array $fields): HttpResponse;
|
||||
}
|
||||
final class HttpResponse { public function __construct(public readonly int $status, public readonly string $body) {} public function json(): array; }
|
||||
|
||||
final class Config { public static function load(string $file): self; public function get(string $key, mixed $default = null): mixed; }
|
||||
|
||||
final class Cache {
|
||||
public function __construct(string $dir);
|
||||
/** @return array{data: array, stored_at: int}|null */
|
||||
public function read(string $key): ?array;
|
||||
public function isFresh(string $key, int $ttl, int $now): bool;
|
||||
public function write(string $key, array $data, int $now): void;
|
||||
}
|
||||
|
||||
final class TokenStore {
|
||||
public function __construct(string $file);
|
||||
/** @return array{access_token:string, refresh_token:string, expires_at:int}|null */
|
||||
public function read(): ?array;
|
||||
public function write(array $token): void; // tmp + rename, chmod 600
|
||||
public function withLock(callable $fn): mixed; // flock LOCK_EX op <file>.lock
|
||||
}
|
||||
|
||||
interface WeatherSource {
|
||||
public function stationsData(): array; // body van getstationsdata
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array; // body van getmeasure
|
||||
public function forecast(float $lat, float $lon, string $tz): array; // "daily" van open-meteo
|
||||
}
|
||||
|
||||
final class StationMapper {
|
||||
/** @return array{timezone:string, lat:?float, lon:?float, device_id:?string, measured_at:?int,
|
||||
* outdoor:array, indoor:list<array>, rain_module_id:?string, sum_rain_24:?float} */
|
||||
public static function map(array $body, ?string $deviceId, array $indoorModules): array;
|
||||
}
|
||||
final class RainCalculator {
|
||||
public static function dateBegin(\DateTimeImmutable $now): \DateTimeImmutable;
|
||||
/** @return array{today:?float, previous_days:list<array{date:string,mm:?float}>, month_total:?float} */
|
||||
public static function compute(array $body, \DateTimeImmutable $now, ?float $sumRain24): array;
|
||||
}
|
||||
final class ForecastMapper { /** @return list<array> */ public static function map(array $daily): array; }
|
||||
|
||||
final class DashboardService {
|
||||
public function __construct(WeatherSource $source, Cache $cache, array $ttl, string $tzFallback, ?float $lat, ?float $lon, array $indoorModules, ?string $deviceId, ?\DateTimeImmutable $now = null);
|
||||
public function build(): array; // payload volgens spec §5
|
||||
}
|
||||
```
|
||||
|
||||
Payloadvorm: exact spec §5.
|
||||
|
||||
---
|
||||
|
||||
## Fase 1: backend met fixtures
|
||||
|
||||
### Task 1: Projectskelet, composer, PHPUnit
|
||||
|
||||
**Files:** `composer.json`, `phpunit.xml`, `tests/bootstrap.php`, `.gitignore`, `config.example.php`, `src/Config.php`, `tests/ConfigTest.php`
|
||||
|
||||
- [ ] `composer.json` met `"autoload": {"psr-4": {"Weerscherm\\": "src/"}}`, `"require": {"php": ">=8.3", "ext-curl": "*", "ext-json": "*"}`, `"require-dev": {"phpunit/phpunit": "^11"}`, script `test`.
|
||||
- [ ] `composer install`.
|
||||
- [ ] Test: `Config::load()` geeft defaults (`timezone` = `Europe/Berlin`, `data_source` = `fixture`, `cache_ttl` = `[stations=>300, measure=>900, forecast=>3600]`) en overschrijft met waarden uit het bestand.
|
||||
- [ ] Implementatie `Config`.
|
||||
- [ ] `composer test` groen. Commit.
|
||||
|
||||
### Task 2: StationMapper
|
||||
|
||||
**Files:** `src/StationMapper.php`, `tests/fixtures/getstationsdata.json`, `tests/StationMapperTest.php`
|
||||
|
||||
Fixture: 1 NAMain (`_id` `70:ee:50:00:00:01`, `module_name` "Woonkamer", `place.timezone` `Europe/Berlin`, `place.location` `[6.55, 51.82]`), NAModule1 buiten (`Temperature 16.4, min_temp 9.2, max_temp 19.8, date_min_temp, date_max_temp, temp_trend "down", time_utc`), 2× NAModule4 ("Slaapkamer" reachable, "Kantoor" `reachable:false` zonder dashboard_data), NAModule3 regenmeter met `sum_rain_24 2.4`.
|
||||
|
||||
Tests:
|
||||
- `timezone`, `lat`, `lon` uit `place` (let op: location is `[lon, lat]`).
|
||||
- `outdoor` bevat `temp 16.4, trend "down", min 9.2, min_at <unix>, max 19.8, max_at <unix>`.
|
||||
- `indoor` heeft 3 items in volgorde NAMain, dan modules; "Kantoor" heeft `temp/min/max null, reachable false`.
|
||||
- `indoor_modules`-config `['70:ee:50:00:00:03' => 'Studeerkamer', '70:ee:50:00:00:01' => 'Living']` bepaalt volgorde en labels; modules die niet in de lijst staan komen daarna in oorspronkelijke volgorde.
|
||||
- Zonder NAModule3: `rain_module_id null, sum_rain_24 null`.
|
||||
- Onbekend `device_id` → eerste station. Leeg `devices` → alle velden null, geen exception.
|
||||
|
||||
### Task 3: RainCalculator
|
||||
|
||||
**Files:** `src/RainCalculator.php`, `tests/fixtures/getmeasure.json`, `tests/fixtures/getmeasure-dst.json`, `tests/RainCalculatorTest.php`
|
||||
|
||||
Body-vorm van getmeasure met `optimize=false`: `{"body": {"<unix>": [mm], ...}}`. Sleutels zijn timestamps van dagwaarden, niet per se middernacht.
|
||||
|
||||
Regels:
|
||||
- `dateBegin(now)` = 00:00 stationstijd van `min(1e van de maand, vandaag − 3 dagen)`.
|
||||
- Elke timestamp → lokale datum `Y-m-d` in de tijdzone van `$now`.
|
||||
- `today` = waarde van vandaag, anders `$sumRain24`.
|
||||
- `previous_days` = 3 datums vóór vandaag, oudste eerst, ontbrekende dag `mm: null`.
|
||||
- `month_total` = som van alle dagen in de huidige kalendermaand inclusief vandaag; `null` als er geen enkele dag is.
|
||||
|
||||
Tests (now = `2026-09-11 14:32 Europe/Berlin`):
|
||||
- Normale week: today 2.4, previous [08→1.2, 09→6.8, 10→0.0], month_total = som van 1 t/m 11.
|
||||
- Timestamps om 03:17 lokale tijd mappen op de juiste datum.
|
||||
- Ontbrekende 9 september → `mm null`, month_total telt de rest.
|
||||
- now = 2 oktober: previous_days = 29, 30 sep en 1 okt; month_total = 1 + 2 okt.
|
||||
- Ontbrekende vandaag → `today` = sumRain24 (2.4); zonder sumRain24 → null.
|
||||
- DST: now = `2026-10-25 12:00` (wissel om 03:00). Timestamp `2026-10-24 23:30 UTC` (= 01:30 lokaal 25 okt) telt als 25 okt; `2026-10-25 23:30 UTC` (= 00:30 lokaal 26 okt) niet als 25 okt.
|
||||
- `dateBegin` op 2 okt = 29 sep 00:00; op 15 sep = 1 sep 00:00.
|
||||
|
||||
### Task 4: ForecastMapper
|
||||
|
||||
**Files:** `src/ForecastMapper.php`, `tests/fixtures/forecast.json`, `tests/ForecastMapperTest.php`
|
||||
|
||||
- Input: Open-Meteo `daily` met arrays `time, weather_code, temperature_2m_max, temperature_2m_min, precipitation_sum, precipitation_probability_max` van 6 elementen.
|
||||
- Output: 5 items (index 1..5): `date, weather_code, temp_max, temp_min, precip_mm, precip_prob`. Ontbrekende (`null`) waarden blijven `null`. Minder dan 6 dagen → zoveel als beschikbaar.
|
||||
|
||||
### Task 5: Cache
|
||||
|
||||
**Files:** `src/Cache.php`, `tests/CacheTest.php`
|
||||
|
||||
- `write` schrijft `<dir>/<key>.json` met `{stored_at, data}` via tmp + rename.
|
||||
- `read` geeft null bij ontbreken of corrupt JSON.
|
||||
- `isFresh(key, ttl, now)`: `now - stored_at < ttl`.
|
||||
- Key wordt gesaneerd tot `[a-z0-9_-]`.
|
||||
|
||||
### Task 6: WeatherSource + FixtureSource
|
||||
|
||||
**Files:** `src/WeatherSource.php`, `src/FixtureSource.php`, `src/Exceptions.php`, `tests/FixtureSourceTest.php`
|
||||
|
||||
- `FixtureSource` bouwt in PHP realistische data **relatief aan nu** (zodat de UI altijd "vandaag" laat zien): stationsdata met `date_min_temp` = vandaag 06:10, `date_max_temp` = vandaag 13:05, `time_utc` = nu − 2 min; measure met 11+ dagen tot vandaag; forecast met 6 dagen vanaf vandaag, codes `[1, 2, 0, 61, 80, 3]`.
|
||||
- Test: `measure()` bevat een sleutel die op vandaag valt; `forecast()['time'][1]` is morgen.
|
||||
|
||||
### Task 7: DashboardService
|
||||
|
||||
**Files:** `src/DashboardService.php`, `tests/DashboardServiceTest.php`
|
||||
|
||||
Gedrag (per bron):
|
||||
1. Cache fresh → gebruik cache, `stale false`.
|
||||
2. Anders `source->…()`; succes → cache write, `stale false`.
|
||||
3. Exception → cache stale (ongeacht leeftijd) → `stale true` + errorcode; geen cache → velden null + errorcode.
|
||||
4. `NetatmoAuthException` → code `netatmo_auth`; andere Netatmo-fouten `netatmo_unavailable`; forecast `forecast_unavailable`.
|
||||
5. Regen: `date_begin` uit `RainCalculator::dateBegin`, `date_end = now`; zonder `rain_module_id` → `rain` alle null zonder call.
|
||||
6. Forecast-locatie: config lat/lon, anders station-lat/lon; beide onbekend → `forecast_unavailable`.
|
||||
7. Tijden als ISO 8601 met offset in de stationstijdzone (`timezone` uit stationsdata, anders fallback).
|
||||
|
||||
Tests met een `FakeSource` (arrays of exceptions per methode) en een tijdelijke cache-dir:
|
||||
- Happy path: payload heeft alle sleutels uit §5, `errors == []`, `outdoor.min_at` = `2026-09-11T06:10:00+02:00`.
|
||||
- Stations-exception zonder cache → `outdoor.temp null`, `station.stale true`, errors `[netatmo_unavailable]`.
|
||||
- Stations-exception mét cache → oude waarden, `station.stale true`.
|
||||
- Auth-exception → `netatmo_auth`.
|
||||
- Forecast-exception → `forecast_stale true`, `forecast_unavailable`, rest intact.
|
||||
- Tweede `build()` binnen de TTL doet geen source-call (teller in FakeSource).
|
||||
|
||||
### Task 8: `public/api/data.php`
|
||||
|
||||
**Files:** `public/api/data.php`, `public/robots.txt`
|
||||
|
||||
- Laadt `vendor/autoload.php`, `config.php` (valt terug op `config.example.php` met een 500 en tekst "config.php ontbreekt" als die er niet is), kiest `FixtureSource` of `LiveSource` (LiveSource komt in fase 3; tot dan `throw` bij `live`).
|
||||
- Headers: `Content-Type: application/json; charset=utf-8`, `Cache-Control: no-store`, `X-Robots-Tag: noindex`.
|
||||
- `robots.txt`: `User-agent: *` / `Disallow: /`.
|
||||
- Check: `php -S 127.0.0.1:8080 -t public` en `curl` geeft geldige JSON met `errors: []`.
|
||||
- Commit. **Fase 1 klaar.**
|
||||
|
||||
---
|
||||
|
||||
## Fase 2: frontend
|
||||
|
||||
### Task 9: format.js met tests
|
||||
|
||||
**Files:** `public/format.js`, `tests/js/format.test.js`, `package.json`
|
||||
|
||||
Exports en gedrag:
|
||||
- `f1(v)`: `16.4 → "16,4"`, `0 → "0,0"`, `null → "–"`.
|
||||
- `fInt(v)`: `18.3 → "18"`, `null → "–"` (afronden met `Math.round`).
|
||||
- `fmm(v)`: `0 → "0 mm"`, `0.4 → "0,4 mm"`, `null → "–"`.
|
||||
- `markerPos(temp, min, max)`: `(16.4, 9.2, 19.8) ≈ 67.9`; `< min → 0`; `> max → 100`; `max == min → 50`; null → 50.
|
||||
- `barHeights(values, maxPx = 70, minPx = 2)`: `[1.2, 6.8, 0, 2.4] → [12.35…, 70, 2, 24.7…]`; alle 0 → alle 2; null telt als 0.
|
||||
- `wmoIcon(code)`: tabel uit spec §4; onbekend → `cloud`.
|
||||
- `trendLabel(t)`: `up → "↗ stijgend"`, `down → "↘ dalend"`, `stable → "→ stabiel"`, anders `""`.
|
||||
- `formatDate(date, tz)`: `"Vrijdag 11 september"` (eerste letter hoofdletter).
|
||||
- `formatTime(iso, tz)`: `"06:10"`; null → `"–"`.
|
||||
- `dayAbbr(dateStr, tz)`: `"2026-09-12" → "Za"`.
|
||||
- `monthName(date, tz)`: `"September"`.
|
||||
- `isOlderThan(iso, now, minutes)`.
|
||||
- `nextMidnight(now, tz)` en `nextTimeAt(now, tz, hour)` geven ms tot het volgende moment.
|
||||
|
||||
`package.json`: `{"private": true, "type": "module", "scripts": {"test": "node --test tests/js"}}`. Tests importeren `../../public/format.js`.
|
||||
|
||||
### Task 10: icons.js
|
||||
|
||||
**Files:** `public/icons.js`
|
||||
|
||||
- `export function icon(name)` → `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">…</svg>`.
|
||||
- `sun`, `partly`, `rain`, `showers` letterlijk uit de mockup.
|
||||
- Nieuw in dezelfde stijl (stroke 2.4, ronde uiteinden, `i-sun`/`i-cloud`/`i-drop`):
|
||||
- `cloud`: alleen de wolkvorm van `rain` maar gecentreerd (y +5).
|
||||
- `fog`: wolk plus 2 horizontale lijnen eronder (`M14 36h20 M18 42h14`, klasse `i-drop`).
|
||||
- `drizzle`: wolk plus 3 korte streepjes (`M18 35l-1 2.5 M26 35l-1 2.5 M34 35l-1 2.5`).
|
||||
- `snow`: wolk plus 3 kleine sterretjes (elk 2 kruisende lijnen van 5 px) in `i-drop`.
|
||||
- `thunder`: wolk plus bliksem `M25 32l-4 7h5l-3 6` in `i-sun`.
|
||||
- Onbekende naam → `cloud`.
|
||||
|
||||
### Task 11: index.html + styles.css (ontwerp A)
|
||||
|
||||
**Files:** `public/index.html`, `public/styles.css`
|
||||
|
||||
- `<html lang="nl">`, viewport `width=device-width, initial-scale=1, viewport-fit=cover`, iOS-meta (§7), `<link rel="manifest">`, `<script type="module" src="app.js">`.
|
||||
- Structuur: `<div id="stage">` (1133×744, `transform-origin: top left`) met daarin dezelfde secties als de mockup: `header.a-top`, `section.a-hero`, `section.a-inside`, `section.a-bottom` (regen + `.a-fc`). Alle dynamische velden hebben `id`'s of `data-*`.
|
||||
- Extra elementen: `#status` (klein, in `.a-right`, voor "geen verbinding sinds HH:MM"), `#banner` (over de bovenzone, verborgen; voor `netatmo_auth` en de eerste-load-fout), `#rotate` (portret-melding, alleen zichtbaar via `@media (orientation: portrait)`), `#loading` ("Gegevens laden…").
|
||||
- CSS: de `.da`-tokens en alle `.a-*`-klassen uit de mockup overgenomen; `body` heeft `background: var(--bg)` en `overflow: hidden`; `#stage` gecentreerd via JS-schaal; `padding` houdt rekening met `env(safe-area-inset-*)`.
|
||||
- `.a-upd.stale { color: var(--warm); }`.
|
||||
|
||||
### Task 12: app.js
|
||||
|
||||
**Files:** `public/app.js`
|
||||
|
||||
Functies:
|
||||
- `fit()`: `scale = Math.min(innerWidth/1133, innerHeight/744)`; stage krijgt `transform: translate(x,y) scale(s)`. Aan `resize` en `orientationchange`.
|
||||
- `render(data)`: vult alle velden met `format.js`; regenstaven via `barHeights`; forecast via `icon(wmoIcon(code))`; indoor onbereikbaar → "–" in `--muted`; `station.stale` of meting ouder dan 30 min → `.a-upd` warm + " verouderd"; `errors` bevat `netatmo_auth` → banner met herstelinstructie.
|
||||
- `refresh()`: `fetch('api/data.php', {cache: 'no-store'})`; bij succes `lastData` bewaren, `lastOk = now`, status leeg; bij fout: laatste data blijft, status "geen verbinding sinds HH:MM" (tijd van laatste succes); eerste load zonder data → banner met foutmelding.
|
||||
- Timers: `setInterval(refresh, 5 min)`; `visibilitychange` → refresh als zichtbaar; klok elke minuut; `setTimeout` tot middernacht → refresh + datum; `setTimeout` tot 04:00 → `location.reload()`.
|
||||
- `navigator.wakeLock?.request('screen')` in try/catch, opnieuw bij `visibilitychange`.
|
||||
- SW-registratie en `controllerchange` → `location.reload()` (fase 5; hier alvast met `if ('serviceWorker' in navigator)`).
|
||||
- Visuele check: `php -S` + browser op 1133×744 naast de mockup (tab A). Commit. **Fase 2 klaar.**
|
||||
|
||||
---
|
||||
|
||||
## Fase 3: Netatmo live
|
||||
|
||||
### Task 13: Http + HttpResponse
|
||||
|
||||
**Files:** `src/Http.php`
|
||||
|
||||
- `Http implements HttpClient`, curl met `CURLOPT_CONNECTTIMEOUT 5`, `CURLOPT_TIMEOUT 8`, `RETURNTRANSFER`, `FOLLOWLOCATION false`. Curl-fout → `UpstreamException`.
|
||||
- `HttpResponse::json()` → `json_decode(..., true, 512, JSON_THROW_ON_ERROR)`, gooit `UpstreamException` bij ongeldige JSON.
|
||||
|
||||
### Task 14: TokenStore
|
||||
|
||||
**Files:** `src/TokenStore.php`, `tests/TokenStoreTest.php`
|
||||
|
||||
- `write`: `json_encode` naar `<file>.tmp.<uniqid>`, `chmod 0600`, `rename`.
|
||||
- `read`: null als bestand ontbreekt of ongeldig.
|
||||
- `withLock(fn)`: opent `<file>.lock`, `flock(LOCK_EX)`, voert fn uit, `LOCK_UN`, geeft resultaat terug; ook bij exception ontgrendelen (`finally`).
|
||||
- Tests: write/read round-trip; bestand heeft mode 0600; `withLock` geeft de returnwaarde terug en laat de lock los na een exception; twee processen (`proc_open` van een klein PHP-script dat 300 ms lockt) serialiseren: het tweede wacht.
|
||||
|
||||
### Task 15: NetatmoClient + tokentests
|
||||
|
||||
**Files:** `src/NetatmoClient.php`, `tests/NetatmoClientTest.php`, `tests/FakeHttp.php`
|
||||
|
||||
- `__construct(HttpClient $http, TokenStore $store, string $clientId, string $clientSecret, ?callable $now = null)`.
|
||||
- `accessToken()`: onder `withLock`: lees token; als `expires_at - now < 300` → `refresh()`. `refresh()` POST `https://api.netatmo.com/oauth2/token` met `grant_type=refresh_token, refresh_token, client_id, client_secret`; response `access_token, refresh_token, expires_in` → `write(['access_token', 'refresh_token' => nieuwe, 'expires_at' => now + expires_in])`. Status 400 met body `error: invalid_grant` → `NetatmoAuthException`. Ontbrekend tokenbestand → `NetatmoAuthException`.
|
||||
- `apiGet(path, query)`: GET met Bearer; bij 401/403 eenmalig `refresh()` + retry; daarna `NetatmoAuthException`. Andere status ≠ 200 → `UpstreamException`. Response `body` teruggeven.
|
||||
- `getStationsData()`: `GET /api/getstationsdata`; `getMeasure($deviceId, $moduleId, $begin, $end)`: `GET /api/getmeasure` met `scale=1day, type=sum_rain, optimize=false, real_time=false`.
|
||||
- Tests met `FakeHttp` (script van responses, registreert requests):
|
||||
- verlopen token → eerst POST refresh, daarna GET met nieuwe Bearer; store bevat nieuwe refresh token.
|
||||
- geldig token → geen refresh.
|
||||
- GET 401 → refresh + retry; tweede 401 → `NetatmoAuthException`.
|
||||
- refresh met `invalid_grant` → `NetatmoAuthException`.
|
||||
- gelijktijdigheid: twee clients op dezelfde store, de refresh van de eerste schrijft; de tweede leest binnen de lock het verse token en refresht niet (FakeHttp telt 1 POST).
|
||||
|
||||
### Task 16: LiveSource, bin/netatmo-init.php, data.php live
|
||||
|
||||
**Files:** `src/LiveSource.php`, `bin/netatmo-init.php`, `public/api/data.php`
|
||||
|
||||
- `LiveSource(NetatmoClient, OpenMeteoClient)` (OpenMeteoClient komt in Task 17; hier alvast als constructorparameter met `forecast()` doorgifte).
|
||||
- `bin/netatmo-init.php <refresh_token> [access_token]`: schrijft `storage/netatmo-token.json` met `expires_at 0` (dwingt refresh bij eerste call), meldt pad en mode.
|
||||
- `data.php`: `data_source === 'live'` → `LiveSource` met `Http`, `TokenStore(storage/netatmo-token.json)`.
|
||||
- `config.php` aanmaken met de echte gegevens, `storage/` aanmaken, init draaien, `curl` op de live API: echte waarden zichtbaar. Commit (zonder config.php/storage). **Fase 3 klaar.**
|
||||
|
||||
---
|
||||
|
||||
## Fase 4: Open-Meteo live
|
||||
|
||||
### Task 17: OpenMeteoClient
|
||||
|
||||
**Files:** `src/OpenMeteoClient.php`, `tests/OpenMeteoClientTest.php`
|
||||
|
||||
- `dailyForecast(float $lat, float $lon, string $tz): array` → GET `https://api.open-meteo.com/v1/forecast` met `latitude, longitude, daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max, timezone=$tz, forecast_days=6`; geeft `['daily']` terug; status ≠ 200 of ontbrekende `daily` → `UpstreamException`.
|
||||
- Test met FakeHttp: query bevat alle parameters; foutstatus → exception.
|
||||
- Live check via `curl` op de API: `forecast` heeft 5 dagen vanaf morgen. Commit. **Fase 4 klaar.**
|
||||
|
||||
---
|
||||
|
||||
## Fase 5: PWA
|
||||
|
||||
### Task 18: manifest, iconen, iOS-meta
|
||||
|
||||
**Files:** `public/manifest.webmanifest`, `public/icons/icon.svg`, `public/icons/icon-{180,192,512}.png`, `public/index.html`
|
||||
|
||||
- Manifest volgens §7 (`name Weerscherm, short_name Weer, display standalone, orientation landscape, start_url "./", scope "./", background_color/theme_color #E8EDF1, icons 192 + 512`).
|
||||
- `icon.svg`: zon/wolk in de iconstijl op `--bg`, 512×512. PNG's renderen met `rsvg-convert` of `sips`/`qlmanage` (macOS); fallback: een klein Node-script met `canvas` is NIET toegestaan (geen deps) → gebruik `qlmanage -t -s 512 -o . icon.svg` en `sips -z` voor 192/180.
|
||||
- `index.html`: `apple-touch-icon` 180, `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style default`, `apple-mobile-web-app-title Weer`.
|
||||
|
||||
### Task 19: service worker
|
||||
|
||||
**Files:** `public/sw.js`, `public/app.js`
|
||||
|
||||
- `CACHE = 'weerscherm-v1'`; `SHELL = ['./', './index.html', './styles.css', './app.js', './format.js', './icons.js', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png', './icons/icon-180.png']`.
|
||||
- `install`: `cache.addAll(SHELL)` + `skipWaiting()`. `activate`: oude caches weg + `clients.claim()`.
|
||||
- `fetch`: URL bevat `/api/data.php` → network-first met `AbortController` timeout 8000 ms; succes → response klonen in cache; fout → cached response met extra header `X-From-Cache: 1` (via `new Response(body, {headers})`), of `503` JSON `{"errors":["offline"]}`. Overige GET → cache-first, netwerk als fallback.
|
||||
- `app.js`: registreert `./sw.js`; `controllerchange` → `location.reload()`; response met `X-From-Cache` → behandelen als "geen verbinding" (status tonen, data wel renderen).
|
||||
- Check: in Chrome DevTools offline zetten → laatste data blijft, status verschijnt. Commit. **Fase 5 klaar.**
|
||||
|
||||
---
|
||||
|
||||
## Fase 6: deploy en README
|
||||
|
||||
### Task 20: OrbStack-vhost, README, deploy
|
||||
|
||||
**Files:** `README.md`, `orbstack/…` (buiten deze repo, in `/Volumes/development/sites/orbstack/divers/weerscherm/`), `deploy/` notities in README
|
||||
|
||||
- OrbStack: `Dockerfile` op `php:8.3-fpm-alpine` (curl-ext zit erin), `docker-compose.yml` in `shared-network`, vhost `weerscherm.test` met docroot `/var/www/html/weerscherm/public`, mount in shared compose, `/etc/hosts`-regel. Alleen uitvoeren als OrbStack draait; anders de bestanden klaarzetten en in de README beschrijven.
|
||||
- README: installatie, `config.php`, Netatmo-koppeling (§3.1), `bin/netatmo-init.php`, tests (`composer test`, `npm test`), lokaal draaien (`php -S` en OrbStack), deploy (Gitea + DeployPad, `storage/` als gedeelde map buiten de release), productie-URL `https://homedisplay.bahne-drei.de/`, iPad-instellingen (beginscherm, Automatisch vergrendelen → Nooit, Begeleide toegang), acceptatiecriteria uit §11 als checklist.
|
||||
- Commit. **Fase 6 klaar.**
|
||||
@@ -0,0 +1,363 @@
|
||||
# Weerscherm: bouwspecificatie v1
|
||||
|
||||
Opdracht voor Claude Code: bouw een PWA die fullscreen draait op een iPad mini (liggend) in de woonkamer. Het scherm toont actuele gegevens van een Netatmo-weerstation en een weersverwachting voor 5 dagen.
|
||||
|
||||
**Ontwerp A** in `docs/design/weerscherm-layouts.html` is leidend voor de layout. Dat bestand is een statische mockup met voorbeelddata. De CSS van ontwerp A (klasse `.da` en alle `.a-*`-klassen) is getest op 1133 × 744 punten en dient als uitgangspunt. De tabbladen B en C zijn niet in scope.
|
||||
|
||||
Werkwijze: lees eerst dit document en de mockup. Stel daarna een plan voor per fase (zie §12) en bouw pas na akkoord.
|
||||
|
||||
---
|
||||
|
||||
## 0. Vooraf in te vullen (Theo)
|
||||
|
||||
| Wat | Waarde |
|
||||
|---|---|
|
||||
| iPad mini-generatie en iPadOS-versie | … |
|
||||
| Productie-hostname en pad | … |
|
||||
| Netatmo `client_id` / `client_secret` | in `config.php`, nooit in git |
|
||||
| Volgorde en labels van de 3 binnenruimtes | … (standaard: `module_name` uit Netatmo) |
|
||||
| Locatie voor de verwachting | standaard de coördinaten van het Netatmo-station |
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
**In v1:**
|
||||
- Buitentemperatuur, met de laagste en hoogste temperatuur van vandaag (inclusief tijdstippen) en de trend.
|
||||
- De 3 binnensensoren, elk met huidige, laagste en hoogste temperatuur van vandaag.
|
||||
- Regen: vandaag, de afgelopen 3 dagen per dag, en het totaal van de huidige maand.
|
||||
- Verwachting voor de komende 5 dagen (vanaf morgen): max/min-temperatuur, neerslag in mm, neerslagkans en een weericoon.
|
||||
- Installeerbaar als PWA op het beginscherm, blijft werken met de laatst bekende data als de verbinding wegvalt.
|
||||
|
||||
**Niet in v1:** nachtmodus (ontwerp C), andere meetwaarden (CO₂, luchtvochtigheid, wind), meerdere stations, een instellingenscherm, pushmeldingen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectuur
|
||||
|
||||
```
|
||||
iPad (PWA) ──GET /api/data.php──▶ PHP-backend ──▶ Netatmo API
|
||||
│ └──▶ Open-Meteo API
|
||||
└── storage/ (tokens + cache)
|
||||
```
|
||||
|
||||
De backend is nodig om drie redenen:
|
||||
- Het Netatmo `client_secret` en de roterende refresh token mogen de browser nooit bereiken.
|
||||
- Een cache houdt het aantal API-calls laag.
|
||||
- De frontend krijgt één JSON-bestand waarin alle waarden al zijn omgerekend.
|
||||
|
||||
**Stack:**
|
||||
- PHP 8.3 zonder framework en zonder runtime-dependencies. Alleen `ext-curl` en `ext-json`; PHPUnit als dev-dependency.
|
||||
- Frontend in vanilla HTML/CSS/JS, zonder build-stap, externe fonts of CDN. Alles wordt lokaal geserveerd.
|
||||
- Uitgangspunt: simpel en onderhoudbaar, ook voor iemand anders die het later overneemt.
|
||||
|
||||
**Mappenstructuur (voorstel):**
|
||||
|
||||
```
|
||||
weerscherm/
|
||||
├── public/ # document root
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ ├── app.js
|
||||
│ ├── icons.js # weericonen (inline SVG-strings)
|
||||
│ ├── sw.js
|
||||
│ ├── manifest.webmanifest
|
||||
│ ├── icons/ # app-iconen 180, 192, 512 px
|
||||
│ └── api/data.php
|
||||
├── src/
|
||||
│ ├── Config.php
|
||||
│ ├── Http.php # dunne curl-wrapper met timeouts
|
||||
│ ├── TokenStore.php # atomisch lezen/schrijven + flock
|
||||
│ ├── NetatmoClient.php
|
||||
│ ├── OpenMeteoClient.php
|
||||
│ ├── Cache.php
|
||||
│ └── DashboardService.php # bouwt de JSON-payload
|
||||
├── bin/netatmo-init.php # eenmalig refresh token opslaan
|
||||
├── storage/ # NIET in git, niet in docroot, schrijfbaar voor www-data
|
||||
├── tests/ # PHPUnit + JSON-fixtures
|
||||
├── docs/design/weerscherm-layouts.html
|
||||
├── config.example.php # config.php staat in .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Netatmo
|
||||
|
||||
### 3.1 App en tokens
|
||||
|
||||
1. Maak een app aan op dev.netatmo.com. Dat levert een `client_id` en `client_secret` op.
|
||||
2. Genereer met de token generator op de app-pagina een token met scope `read_station`.
|
||||
3. Sla de token op met `php bin/netatmo-init.php <refresh_token>`. Dat schrijft naar `storage/netatmo-token.json`.
|
||||
|
||||
**Tokenbeheer:**
|
||||
- Een access token is ongeveer 3 uur geldig. Ververs hem via `POST https://api.netatmo.com/oauth2/token` met `grant_type=refresh_token`.
|
||||
- **De refresh token roteert.** Het antwoord bevat een nieuwe refresh token. Sla die direct op en gebruik vanaf dan altijd de nieuwste.
|
||||
- Schrijf atomisch (tijdelijk bestand + `rename`) en onder een `flock`. Anders kunnen twee gelijktijdige requests dubbel verversen en elkaars token ongeldig maken.
|
||||
- Ververs proactief als de access token binnen 5 minuten verloopt, en eenmalig opnieuw bij een 401/403.
|
||||
- Geeft Netatmo `invalid_grant` terug, dan retourneert de API de fout `netatmo_auth`. De frontend toont dan "Netatmo-koppeling verlopen: voer bin/netatmo-init.php opnieuw uit".
|
||||
|
||||
### 3.2 Stationsdata
|
||||
|
||||
`GET https://api.netatmo.com/api/getstationsdata` met `Authorization: Bearer <token>`. Kies het station via `device_id` uit de config, of standaard het eerste station.
|
||||
|
||||
| Nodig | Bron in response |
|
||||
|---|---|
|
||||
| Tijdzone, locatie | `body.devices[i].place.timezone`, `place.location` (= `[lon, lat]`) |
|
||||
| Buiten | module met `type = NAModule1` → `dashboard_data.Temperature`, `min_temp`, `max_temp`, `date_min_temp`, `date_max_temp` (unix), `temp_trend` (`up`/`down`/`stable`), `time_utc` |
|
||||
| Binnen (3×) | het basisstation zelf (`type = NAMain`, eigen `dashboard_data`) plus modules met `type = NAModule4`; naam uit `module_name`. Volgorde en labels zijn te overschrijven in de config op basis van `_id` |
|
||||
| Regenmeter | module met `type = NAModule3`: de `_id` is nodig voor §3.3; `dashboard_data.sum_rain_24` dient alleen als fallback |
|
||||
|
||||
Is een module onbereikbaar (`reachable: false` of geen `dashboard_data`), zet dan alle waarden van die module op `null`. De backend mag hier niet op crashen.
|
||||
|
||||
### 3.3 Regen per dag
|
||||
|
||||
Eén call levert vandaag, de vorige 3 dagen en het maandtotaal op:
|
||||
|
||||
```
|
||||
GET https://api.netatmo.com/api/getmeasure
|
||||
device_id = <_id van NAMain>
|
||||
module_id = <_id van NAModule3>
|
||||
scale = 1day
|
||||
type = sum_rain
|
||||
date_begin = 00:00 lokale tijd van min(1e van de maand, vandaag − 3 dagen)
|
||||
date_end = nu
|
||||
optimize = false
|
||||
real_time = false
|
||||
```
|
||||
|
||||
**Verwerking:**
|
||||
- Koppel elke timestamp aan een **lokale datum in de tijdzone van het station**. Bij `real_time=false` liggen de timestamps van dagwaarden niet op middernacht, dus ga daar niet van uit.
|
||||
- `today`: de waarde voor de datum van vandaag. Ontbreekt die, gebruik dan `sum_rain_24`.
|
||||
- `previous_days`: de 3 datums vóór vandaag, oudste eerst. Een ontbrekende dag wordt `null` en de frontend toont dan "–".
|
||||
- `month_total`: de som van alle dagen in de huidige kalendermaand, inclusief vandaag. Op de 1e t/m 3e van de maand vallen de vorige dagen in de vorige maand. Die tellen wél mee in `previous_days`, maar niet in het maandtotaal.
|
||||
|
||||
### 3.4 Cache en frequentie
|
||||
|
||||
Het station stuurt ongeveer elke 10 minuten nieuwe metingen. Cache `getstationsdata` 5 minuten en `getmeasure` 15 minuten. Dat blijft ruim binnen de rate limits van Netatmo.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open-Meteo (verwachting)
|
||||
|
||||
Gratis voor niet-commercieel gebruik, geen API-key nodig.
|
||||
|
||||
```
|
||||
GET https://api.open-meteo.com/v1/forecast
|
||||
latitude, longitude = config, anders stationslocatie
|
||||
daily = weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max
|
||||
timezone = Europe/Berlin
|
||||
forecast_days = 6
|
||||
```
|
||||
|
||||
Gebruik index 1 t/m 5 (index 0 is vandaag) en cache 60 minuten.
|
||||
|
||||
**WMO-code → icoon:**
|
||||
|
||||
| Code | Betekenis | Icoon |
|
||||
|---|---|---|
|
||||
| 0 | onbewolkt | `sun` |
|
||||
| 1, 2 | licht / half bewolkt | `partly` |
|
||||
| 3 | bewolkt | `cloud` |
|
||||
| 45, 48 | mist | `fog` |
|
||||
| 51–57 | motregen | `drizzle` |
|
||||
| 61–67 | regen | `rain` |
|
||||
| 80–82 | regenbuien | `showers` |
|
||||
| 71–77, 85, 86 | sneeuw | `snow` |
|
||||
| 95–99 | onweer | `thunder` |
|
||||
|
||||
De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`, `fog`, `drizzle`, `snow` en `thunder` in dezelfde stijl:
|
||||
- `viewBox 0 0 48 48`, stroke 2.4, ronde uiteinden.
|
||||
- Zon-delen krijgen de klasse `i-sun`, wolk-vullingen `i-cloud` en druppels of vlokken `i-drop`.
|
||||
|
||||
---
|
||||
|
||||
## 5. API-contract: `GET /api/data.php`
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": "2026-09-11T14:32:05+02:00",
|
||||
"timezone": "Europe/Berlin",
|
||||
"station": { "measured_at": "2026-09-11T14:30:12+02:00", "stale": false },
|
||||
"outdoor": {
|
||||
"temp": 16.4, "trend": "down",
|
||||
"min": 9.2, "min_at": "2026-09-11T06:10:00+02:00",
|
||||
"max": 19.8, "max_at": "2026-09-11T13:05:00+02:00"
|
||||
},
|
||||
"indoor": [
|
||||
{ "id": "70:ee:50:…", "name": "Woonkamer", "temp": 21.3, "min": 20.4, "max": 22.1, "reachable": true }
|
||||
],
|
||||
"rain": {
|
||||
"today": 2.4,
|
||||
"previous_days": [
|
||||
{ "date": "2026-09-08", "mm": 1.2 },
|
||||
{ "date": "2026-09-09", "mm": 6.8 },
|
||||
{ "date": "2026-09-10", "mm": 0.0 }
|
||||
],
|
||||
"month_total": 38.5,
|
||||
"stale": false
|
||||
},
|
||||
"forecast": [
|
||||
{ "date": "2026-09-12", "weather_code": 2, "temp_max": 18.3, "temp_min": 10.1,
|
||||
"precip_mm": 0.4, "precip_prob": 20 }
|
||||
],
|
||||
"forecast_stale": false,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
**Regels:**
|
||||
- Geef ruwe getallen door. De frontend rondt af en formatteert.
|
||||
- Tijden zijn ISO 8601 met offset, in de tijdzone van het station. Onbekende waarden zijn `null`.
|
||||
- Bij een storing bij één bron: serveer de laatst gecachte data voor die bron, zet `stale: true` en voeg een code toe aan `errors` (`netatmo_auth`, `netatmo_unavailable`, `forecast_unavailable`). De HTTP-status blijft 200 zolang er iets te tonen is.
|
||||
- Stuur `Cache-Control: no-store` mee.
|
||||
- Met `DATA_SOURCE=fixture` in de config levert de API vaste voorbeelddata. Dan kan de frontend gebouwd worden zonder Netatmo-koppeling.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
### 6.1 Canvas en schaal
|
||||
|
||||
- Ontwerp op een vast canvas van **1133 × 744**, het formaat van de iPad mini 6 in liggende stand.
|
||||
- Schaal het canvas met `transform: scale()` op `min(innerWidth/1133, innerHeight/744)` en centreer het. Vul de ruimte eromheen op met de achtergrondkleur. Zo klopt de layout ook op een mini 5 (1024 × 768).
|
||||
- Alleen liggend. In portretstand verschijnt de melding "Draai de iPad een kwartslag".
|
||||
- Gebruik `viewport-fit=cover` en houd rekening met `env(safe-area-inset-*)`.
|
||||
|
||||
### 6.2 Design tokens (uit de mockup)
|
||||
|
||||
| Token | Waarde | Gebruik |
|
||||
|---|---|---|
|
||||
| `--bg` | `#E8EDF1` | achtergrond, ook de vulling van wolk-iconen |
|
||||
| `--ink` | `#1B2A35` | hoofdtekst |
|
||||
| `--muted` | `#5F707C` | labels, secundaire tekst |
|
||||
| `--line` | `#C6D0D8` | scheidingslijnen |
|
||||
| `--cold` | `#3A75A3` | minimumtemperaturen |
|
||||
| `--warm` | `#B8761C` | maximumtemperaturen |
|
||||
| `--rain` | `#2E6A99` | regen |
|
||||
| `--sun` | `#D39A2C` | zon in iconen |
|
||||
|
||||
- Font: `-apple-system, system-ui, sans-serif` (SF Pro op de iPad), met overal `font-variant-numeric: tabular-nums`.
|
||||
- Grid: padding `30px 44px`, kolommen `1fr 330px`, rijen `40px 1fr 232px`, `column-gap: 56px`.
|
||||
- Onderste zone: kolommen `390px 1fr`, `column-gap: 36px`. Deze waarden zijn in de mockup al gecorrigeerd.
|
||||
|
||||
### 6.3 Zones en formattering
|
||||
|
||||
**Algemene notatie:** `nl-NL`, decimale komma, tijden in de tijdzone van het station via `Intl.DateTimeFormat`.
|
||||
|
||||
- **Kopregel:** datum ("Vrijdag 11 september") links. Rechts "meting HH:MM" (uit `station.measured_at`) en de klok (apparaattijd, per minuut bijgewerkt).
|
||||
- **Buiten:**
|
||||
- De temperatuur staat groot, met 1 decimaal ("16,4°"). Het label heeft een trend-aanduiding: `up` → "↗ stijgend", `down` → "↘ dalend", `stable` → "→ stabiel".
|
||||
- Daaronder de gradiëntbalk van `--cold` naar `--warm`. De marker staat op `(temp − min) / (max − min)`, begrensd op 0–100%. Bij `max == min` staat hij op 50%.
|
||||
- Onder de balk: "9,2° laagst om 06:10" en "19,8° hoogst om 13:05".
|
||||
- **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
|
||||
- **Regen (linksonder):**
|
||||
- "Vandaag" groot, in mm met 1 decimaal.
|
||||
- Daarnaast 4 staafjes: de 3 vorige dagen (labels "Di", "Wo", "Do") en vandaag, uitgelicht. De hoogte is `mm / max(4 waarden) × 70px`, met een minimum van 2px. Zijn alle waarden 0, dan zijn alle staafjes 2px.
|
||||
- Daaronder "September 38,5 mm".
|
||||
- **Verwachting (rechtsonder):** 5 kolommen met dagafkorting ("Za"), icoon (58px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm".
|
||||
|
||||
### 6.4 Verversen
|
||||
|
||||
- Haal `/api/data.php` op bij het laden, daarna elke 5 minuten, en direct bij `visibilitychange` naar zichtbaar.
|
||||
- Om 00:00 lokale tijd: opnieuw ophalen en de datum bijwerken.
|
||||
- Dagelijks rond 04:00 een volledige `location.reload()`. Dat voorkomt geheugenlekken en pakt nieuwe deploys op.
|
||||
- Optioneel: vraag een `navigator.wakeLock` aan als die beschikbaar is. De primaire oplossing is Automatisch vergrendelen op "Nooit" in de iPad-instellingen.
|
||||
|
||||
### 6.5 Verouderde data en fouten
|
||||
|
||||
- Is `station.measured_at` ouder dan 30 minuten, dan wordt "meting HH:MM" `--warm` met de toevoeging "verouderd".
|
||||
- Is de API niet bereikbaar, laat dan de laatste data staan en toon klein in de kopregel "geen verbinding sinds HH:MM".
|
||||
- Bij `netatmo_auth`: toon een duidelijke melding over de hele bovenzone met de herstelinstructie uit §3.1.
|
||||
- Nooit een leeg of wit scherm. Bij de allereerste load zonder data: toon "Gegevens laden…" en daarna een concrete foutmelding.
|
||||
|
||||
### 6.6 Browserdoel
|
||||
|
||||
Hangt af van de iPadOS-versie in §0:
|
||||
- **iPadOS 15 of nieuwer:** moderne JS/CSS is prima.
|
||||
- **Ouder:** schrijf ES2017 (geen optional chaining of `??`). In CSS geen `gap` op flexbox (gebruik margins), geen `inset` en geen `aspect-ratio`. Grid-`gap` mag wel.
|
||||
|
||||
---
|
||||
|
||||
## 7. PWA
|
||||
|
||||
- **`manifest.webmanifest`:**
|
||||
- `name: "Weerscherm"`, `short_name: "Weer"`, `display: "standalone"`, `orientation: "landscape"`.
|
||||
- `start_url` en `scope` relatief (`"./"`).
|
||||
- `background_color` en `theme_color`: `#E8EDF1`.
|
||||
- Iconen van 192 en 512 px.
|
||||
- **iOS-meta:** `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` (`default`), een `apple-touch-icon` van 180 px, `apple-mobile-web-app-title`.
|
||||
- **Service worker:**
|
||||
- De app-shell (html, css, js, iconen) is cache-first met een geversioneerde cachenaam.
|
||||
- `/api/data.php` is network-first met een timeout van 8 s. Val terug op de laatst gecachte response en markeer die in de UI als verouderd.
|
||||
- Gebruik `skipWaiting` + `clients.claim`. De pagina herlaadt zichzelf bij `controllerchange`.
|
||||
- **App-icoon:** een eenvoudig zon/wolk-icoon in de stijl van de weericonen, op `--bg`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Configuratie en beveiliging
|
||||
|
||||
`config.example.php` staat in git, `config.php` niet. Sleutels:
|
||||
- `netatmo_client_id`, `netatmo_client_secret`
|
||||
- `netatmo_device_id` (optioneel)
|
||||
- `timezone` (standaard `Europe/Berlin`)
|
||||
- `forecast_lat` / `forecast_lon` (optioneel)
|
||||
- `indoor_modules`: optioneel, een lijst van `_id` → label in de gewenste volgorde
|
||||
- `data_source` (`live` | `fixture`)
|
||||
- `cache_ttl` per bron
|
||||
|
||||
**Beveiliging:**
|
||||
- `storage/` staat buiten de document root en wordt nooit geserveerd. Tokenbestanden krijgen `chmod 600`.
|
||||
- Er komen geen secrets of tokens in responses, logs of de frontend.
|
||||
- Stuur `X-Robots-Tag: noindex` mee en zet een `robots.txt` die alles blokkeert.
|
||||
- Standaard geen login. Dat is proportioneel voor weerdata, en HTTP basic auth werkt slecht in iOS-standalone-PWA's. Plaats de app wel op een niet-voor-de-hand-liggend (sub)domein of pad.
|
||||
|
||||
---
|
||||
|
||||
## 9. Lokaal ontwikkelen en deployen
|
||||
|
||||
- **Lokaal:** via de bestaande OrbStack shared-Apache-setup met een PHP 8.3-container, onder `weerscherm.test`. Begin met `data_source = fixture`.
|
||||
- **Visuele check:** een browservenster van exact 1133 × 744 naast `docs/design/weerscherm-layouts.html` (tab A).
|
||||
- **Deploy:** Git op de self-hosted Gitea (squash-and-merge), deploy via DeployPad. `storage/` moet deploys overleven: zet hem buiten de release-map of maak er een gedeelde map van.
|
||||
- **README:** installatie, Netatmo-koppeling (§3.1), config, deploy, en de iPad-instellingen:
|
||||
- toevoegen aan beginscherm
|
||||
- Automatisch vergrendelen → Nooit
|
||||
- Begeleide toegang
|
||||
|
||||
---
|
||||
|
||||
## 10. Tests
|
||||
|
||||
**PHPUnit, met JSON-fixtures van echte responses** (geanonimiseerde MAC-adressen):
|
||||
- Mapping van `getstationsdata` naar de payload, inclusief een onbereikbare module en een ontbrekende regenmeter.
|
||||
- Regenberekening: datum-mapping in de stationstijdzone, de maandgrens (1e–3e van de maand), een ontbrekende dag, en de dag van de zomertijdwissel.
|
||||
- Token-refresh: de nieuwe refresh token wordt opgeslagen, een gelijktijdige refresh verloopt goed dankzij de lock, en `invalid_grant` geeft de fout `netatmo_auth`.
|
||||
- Stale-fallback: bij een upstream-fout wordt de gecachte data geserveerd met `stale: true`.
|
||||
|
||||
**Frontend:** houd formatteer- en rekenfuncties puur en los testbaar. Denk aan de decimale komma, de markerpositie, de staafhoogtes en de WMO-mapping. Vitest mag als dev-dependency, maar zonder build-stap voor productie.
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptatiecriteria
|
||||
|
||||
- [ ] Op de iPad mini, gestart vanaf het beginscherm, draait de app fullscreen zonder Safari-balken en klopt de layout visueel met ontwerp A.
|
||||
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal.
|
||||
- [ ] De verwachting toont 5 dagen vanaf morgen met het juiste icoon per WMO-code.
|
||||
- [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten.
|
||||
- [ ] Wifi uit: de laatste data blijft staan, met een melding "geen verbinding sinds". Wifi aan: herstelt vanzelf.
|
||||
- [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
|
||||
- [ ] Geen secrets in git, in de browser of in responses. `storage/` is niet via HTTP bereikbaar.
|
||||
- [ ] De PHPUnit-tests uit §10 slagen.
|
||||
|
||||
---
|
||||
|
||||
## 12. Werkvolgorde
|
||||
|
||||
1. **Backend met fixtures:** mappenstructuur, config, `DashboardService` op fixtures, `/api/data.php`, tests voor de mapping en de regenberekening.
|
||||
2. **Frontend:** ontwerp A op basis van de fixture-API, met schaal, formattering, verversen en de foutstatussen.
|
||||
3. **Netatmo live:** `bin/netatmo-init.php`, TokenStore met lock, `getstationsdata` en `getmeasure`, cache, stale-fallback, tokentests.
|
||||
4. **Open-Meteo live:** client, cache, WMO-mapping en de ontbrekende iconen.
|
||||
5. **PWA:** manifest, iOS-meta, service worker, app-iconen.
|
||||
6. **Deploy en README:** productieconfig, DeployPad, een test op de iPad aan de hand van de acceptatiecriteria.
|
||||
|
||||
Sluit elke fase af met iets dat te testen is, en meld afwijkingen van deze spec voordat je ze doorvoert.
|
||||
Reference in New Issue
Block a user