Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350dafe591 | ||
|
|
48d68b86fa |
@@ -252,6 +252,10 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
|
|||||||
- 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%.
|
- 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".
|
- 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`.
|
- **Binnen (rechts):** 3 rijen met naam, temperatuur (1 decimaal) en "↓ min° ↑ max°". Een onbereikbare module toont "–" in `--muted`.
|
||||||
|
_Toegevoegd 2026-09-13: een module is tikbaar. Een tik zet die module bovenaan de lijst (de overige twee
|
||||||
|
behouden hun onderlinge volgorde) en vergroot alleen het temperatuurgetal van 50 naar 92px. Nogmaals tikken
|
||||||
|
op de bovenste (vastgezette) module maakt hem weer los. De keuze blijft staan bij de 5-minuten-verversing en
|
||||||
|
reset bij een volledige herlaad. Zie `pinRoom()` in `public/format.js`._
|
||||||
- **Regen (linksonder):**
|
- **Regen (linksonder):**
|
||||||
- "Vandaag" groot, in mm met 1 decimaal.
|
- "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.
|
- 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.
|
||||||
|
|||||||
+16
-6
@@ -2,7 +2,7 @@ import {
|
|||||||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||||||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||||||
co2Level, fPct, fHpa, seriesRange, sparklinePath,
|
co2Level, fPct, fHpa, seriesRange, sparklinePath,
|
||||||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
import { icon, windArrow } from './icons.js';
|
import { icon, windArrow } from './icons.js';
|
||||||
|
|
||||||
@@ -22,10 +22,11 @@ const stage = $('stage');
|
|||||||
const viewport = $('viewport');
|
const viewport = $('viewport');
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
data: null, // laatste payload, ook als die uit de SW-cache komt
|
data: null, // laatste payload, ook als die uit de SW-cache komt
|
||||||
tz: FALLBACK_TZ,
|
tz: FALLBACK_TZ,
|
||||||
lastOk: null, // Date van de laatste geslaagde live-fetch
|
lastOk: null, // Date van de laatste geslaagde live-fetch
|
||||||
offlineSince: null // Date vanaf wanneer er geen verbinding is
|
offlineSince: null, // Date vanaf wanneer er geen verbinding is
|
||||||
|
pinnedRoomId: null, // id van de module die vastgezet is bovenaan; blijft staan tot opnieuw getikt of een volledige herlaad
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- schaal ---------- */
|
/* ---------- schaal ---------- */
|
||||||
@@ -71,11 +72,13 @@ function renderOutdoor(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRooms(rooms) {
|
function renderRooms(rooms) {
|
||||||
const html = rooms.map((r) => {
|
const ordered = pinRoom(rooms, state.pinnedRoomId);
|
||||||
|
const html = ordered.map((r) => {
|
||||||
const off = !r.reachable || r.temp === null;
|
const off = !r.reachable || r.temp === null;
|
||||||
const co2 = co2Level(off ? null : r.co2);
|
const co2 = co2Level(off ? null : r.co2);
|
||||||
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
|
||||||
return `<div class="a-room${off ? ' off' : ''}">
|
const pinned = r.id === state.pinnedRoomId;
|
||||||
|
return `<div class="a-room${off ? ' off' : ''}${pinned ? ' pinned' : ''}" data-id="${esc(r.id)}">
|
||||||
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
|
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
|
||||||
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</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><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
|
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
|
||||||
@@ -318,6 +321,13 @@ $('forecast').addEventListener('click', (e) => {
|
|||||||
});
|
});
|
||||||
$('hour-overlay').addEventListener('click', closeHourOverlay);
|
$('hour-overlay').addEventListener('click', closeHourOverlay);
|
||||||
|
|
||||||
|
$('rooms').addEventListener('click', (e) => {
|
||||||
|
const room = e.target.closest('.a-room');
|
||||||
|
if (!room || !state.data) return;
|
||||||
|
state.pinnedRoomId = state.pinnedRoomId === room.dataset.id ? null : room.dataset.id;
|
||||||
|
renderRooms(state.data.indoor);
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', fit);
|
window.addEventListener('resize', fit);
|
||||||
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
|
window.addEventListener('orientationchange', () => setTimeout(fit, 50));
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
|||||||
@@ -205,3 +205,17 @@ export function formatFullDate(dateStr, tz) {
|
|||||||
if (Number.isNaN(d.getTime())) return DASH;
|
if (Number.isNaN(d.getTime())) return DASH;
|
||||||
return formatDate(d, tz);
|
return formatDate(d, tz);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Herschikt kamers zodat de kamer met `pinnedId` vooraan staat; de rest behoudt de onderlinge volgorde.
|
||||||
|
* Zonder pinnedId, of als die niet voorkomt of al vooraan staat, blijft de oorspronkelijke array (referentie) terug.
|
||||||
|
*/
|
||||||
|
export function pinRoom(rooms, pinnedId) {
|
||||||
|
if (!pinnedId) return rooms;
|
||||||
|
const index = rooms.findIndex((r) => r.id === pinnedId);
|
||||||
|
if (index <= 0) return rooms;
|
||||||
|
const copy = rooms.slice();
|
||||||
|
const [pinned] = copy.splice(index, 1);
|
||||||
|
copy.unshift(pinned);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -108,8 +108,9 @@ html, body {
|
|||||||
.pill.medium { background: var(--warm); }
|
.pill.medium { background: var(--warm); }
|
||||||
.pill.bad { background: var(--bad); }
|
.pill.bad { background: var(--bad); }
|
||||||
.pill.unknown { background: var(--line); color: var(--muted); }
|
.pill.unknown { background: var(--line); color: var(--muted); }
|
||||||
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; }
|
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; transition: font-size .25s ease; }
|
||||||
.a-room.off .a-rtemp { color: var(--muted); }
|
.a-room.off .a-rtemp { color: var(--muted); }
|
||||||
|
.a-room.pinned .a-rtemp { font-size: 92px; }
|
||||||
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
|
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
|
||||||
.a-rmm .rv { color: var(--muted); }
|
.a-rmm .rv { color: var(--muted); }
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
|
||||||
|
|
||||||
const VERSION = 'weerscherm-v5';
|
const VERSION = 'weerscherm-v6';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'./',
|
'./',
|
||||||
'./index.html',
|
'./index.html',
|
||||||
|
|||||||
@@ -123,11 +123,18 @@ final class DashboardService
|
|||||||
return ['days' => [], 'stale' => true];
|
return ['days' => [], 'stale' => true];
|
||||||
}
|
}
|
||||||
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
|
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
|
||||||
if ($result['data'] === null) {
|
$daily = is_array($result['data']['daily'] ?? null) ? $result['data']['daily'] : null;
|
||||||
return ['days' => [], 'stale' => $result['stale']];
|
if ($daily === null) {
|
||||||
|
// Data ontbreekt (echte storing, al gemeld door fetch()), of de cache staat nog in een ouder
|
||||||
|
// formaat na een deploy dat de opgeslagen vorm wijzigde. In beide gevallen liever zichtbaar
|
||||||
|
// niets tonen dan stil een lege verwachting zonder foutmelding.
|
||||||
|
if ($result['data'] !== null) {
|
||||||
|
$this->addError(self::ERR_FORECAST);
|
||||||
|
}
|
||||||
|
return ['days' => [], 'stale' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
$days = ForecastMapper::map($result['data']['daily'] ?? []);
|
$days = ForecastMapper::map($daily);
|
||||||
$hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz);
|
$hourlyByDate = HourlyForecastMapper::mapByDate($result['data']['hourly'] ?? [], $tz);
|
||||||
foreach ($days as &$day) {
|
foreach ($days as &$day) {
|
||||||
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
|
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
|
||||||
|
|||||||
@@ -182,6 +182,22 @@ final class DashboardServiceTest extends TestCase
|
|||||||
self::assertSame(16.4, $p['outdoor']['temp']);
|
self::assertSame(16.4, $p['outdoor']['temp']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLegacyCachedForecastShapeIsTreatedAsUnavailableNotSilentlyEmpty(): void
|
||||||
|
{
|
||||||
|
// Vóór 2026-09-13 cachete DashboardService het platte "daily"-blok direct onder de sleutel
|
||||||
|
// "forecast". Na de uitbreiding met uurdata verwacht de code {daily, hourly}. Een nog niet
|
||||||
|
// verlopen cache in het oude formaat mag niet stil een lege verwachting zonder foutmelding geven.
|
||||||
|
$legacyDailyOnly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
|
||||||
|
(new Cache($this->dir))->write('forecast', $legacyDailyOnly, (new \DateTimeImmutable('2026-09-11 12:30:00 UTC'))->getTimestamp());
|
||||||
|
|
||||||
|
$p = $this->service()->build();
|
||||||
|
|
||||||
|
self::assertSame([], $p['forecast']);
|
||||||
|
self::assertTrue($p['forecast_stale']);
|
||||||
|
self::assertSame(['forecast_unavailable'], $p['errors']);
|
||||||
|
self::assertSame(0, $this->source->calls['forecast'], 'de cache was nog vers, dus geen live call verwacht');
|
||||||
|
}
|
||||||
|
|
||||||
public function testSecondBuildWithinTtlUsesCache(): void
|
public function testSecondBuildWithinTtlUsesCache(): void
|
||||||
{
|
{
|
||||||
$this->service()->build();
|
$this->service()->build();
|
||||||
|
|||||||
+22
-1
@@ -121,7 +121,7 @@ test('msUntil computes the wait until the next local time', () => {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
||||||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom,
|
||||||
} from '../../public/format.js';
|
} from '../../public/format.js';
|
||||||
|
|
||||||
test('co2Level uses the Netatmo thresholds', () => {
|
test('co2Level uses the Netatmo thresholds', () => {
|
||||||
@@ -187,3 +187,24 @@ test('formatFullDate matches formatDate for the same calendar date', () => {
|
|||||||
assert.equal(formatFullDate('2026-09-14', TZ), 'Maandag 14 september');
|
assert.equal(formatFullDate('2026-09-14', TZ), 'Maandag 14 september');
|
||||||
assert.equal(formatFullDate('kapot', TZ), '–');
|
assert.equal(formatFullDate('kapot', TZ), '–');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pinRoom moves the matching room to the front, keeping the others in order', () => {
|
||||||
|
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
assert.deepEqual(pinRoom(rooms, 'b').map((r) => r.id), ['b', 'a', 'c']);
|
||||||
|
assert.deepEqual(pinRoom(rooms, 'c').map((r) => r.id), ['c', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinRoom returns the original array unchanged without a match, id, or when already first', () => {
|
||||||
|
const rooms = [{ id: 'a' }, { id: 'b' }];
|
||||||
|
assert.equal(pinRoom(rooms, null), rooms);
|
||||||
|
assert.equal(pinRoom(rooms, undefined), rooms);
|
||||||
|
assert.equal(pinRoom(rooms, 'x'), rooms, 'onbekend id: geen match, ongewijzigd terug');
|
||||||
|
assert.equal(pinRoom(rooms, 'a'), rooms, 'al vooraan: geen kopie nodig');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinRoom does not mutate the input array', () => {
|
||||||
|
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
const original = rooms.slice();
|
||||||
|
pinRoom(rooms, 'c');
|
||||||
|
assert.deepEqual(rooms, original);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user