Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
350dafe591 | ||
|
|
48d68b86fa | ||
|
|
b6f4836b12 |
@@ -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%.
|
||||
- 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`.
|
||||
_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):**
|
||||
- "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.
|
||||
|
||||
+16
-6
@@ -2,7 +2,7 @@ import {
|
||||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||||
co2Level, fPct, fHpa, seriesRange, sparklinePath,
|
||||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
||||
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom,
|
||||
} from './format.js';
|
||||
import { icon, windArrow } from './icons.js';
|
||||
|
||||
@@ -22,10 +22,11 @@ const stage = $('stage');
|
||||
const viewport = $('viewport');
|
||||
|
||||
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,
|
||||
lastOk: null, // Date van de laatste geslaagde live-fetch
|
||||
offlineSince: null // Date vanaf wanneer er geen verbinding is
|
||||
lastOk: null, // Date van de laatste geslaagde live-fetch
|
||||
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 ---------- */
|
||||
@@ -71,11 +72,13 @@ function renderOutdoor(o) {
|
||||
}
|
||||
|
||||
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 co2 = co2Level(off ? null : r.co2);
|
||||
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-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>
|
||||
@@ -318,6 +321,13 @@ $('forecast').addEventListener('click', (e) => {
|
||||
});
|
||||
$('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('orientationchange', () => setTimeout(fit, 50));
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
|
||||
@@ -205,3 +205,17 @@ export function formatFullDate(dateStr, tz) {
|
||||
if (Number.isNaN(d.getTime())) return DASH;
|
||||
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.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-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.pinned .a-rtemp { font-size: 92px; }
|
||||
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
|
||||
.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. */
|
||||
|
||||
const VERSION = 'weerscherm-v5';
|
||||
const VERSION = 'weerscherm-v6';
|
||||
const SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
||||
@@ -123,11 +123,18 @@ final class DashboardService
|
||||
return ['days' => [], 'stale' => true];
|
||||
}
|
||||
$result = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
|
||||
if ($result['data'] === null) {
|
||||
return ['days' => [], 'stale' => $result['stale']];
|
||||
$daily = is_array($result['data']['daily'] ?? null) ? $result['data']['daily'] : null;
|
||||
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);
|
||||
foreach ($days as &$day) {
|
||||
$day['hourly'] = $hourlyByDate[$day['date']] ?? [];
|
||||
|
||||
@@ -182,6 +182,22 @@ final class DashboardServiceTest extends TestCase
|
||||
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
|
||||
{
|
||||
$this->service()->build();
|
||||
|
||||
+22
-1
@@ -121,7 +121,7 @@ test('msUntil computes the wait until the next local time', () => {
|
||||
|
||||
import {
|
||||
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
||||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
||||
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom,
|
||||
} from '../../public/format.js';
|
||||
|
||||
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('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