diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a02087 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "weerscherm", + "private": true, + "type": "module", + "scripts": { + "test": "node --test \"tests/js/**/*.test.js\"" + } +} diff --git a/public/format.js b/public/format.js new file mode 100644 index 0000000..2d2780f --- /dev/null +++ b/public/format.js @@ -0,0 +1,124 @@ +/** + * Pure formatteer- en rekenfuncties voor het weerscherm. Geen DOM, geen fetch: los testbaar met node --test. + * Notatie nl-NL met decimale komma; tijden in de tijdzone van het station. + */ + +const DASH = '–'; + +const WMO_ICONS = [ + [[0], 'sun'], + [[1, 2], 'partly'], + [[3], 'cloud'], + [[45, 48], 'fog'], + [[51, 52, 53, 54, 55, 56, 57], 'drizzle'], + [[61, 62, 63, 64, 65, 66, 67], 'rain'], + [[80, 81, 82], 'showers'], + [[71, 72, 73, 74, 75, 76, 77, 85, 86], 'snow'], + [[95, 96, 97, 98, 99], 'thunder'], +]; + +const TRENDS = { up: '↗ stijgend', down: '↘ dalend', stable: '→ stabiel' }; + +function isNum(v) { + return typeof v === 'number' && Number.isFinite(v); +} + +function cap(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** 16.4 -> "16,4"; null -> "–" */ +export function f1(v) { + return isNum(v) ? v.toFixed(1).replace('.', ',') : DASH; +} + +/** 18.3 -> "18"; null -> "–" */ +export function fInt(v) { + return isNum(v) ? String(Math.round(v)) : DASH; +} + +/** 0 -> "0 mm"; 0.4 -> "0,4 mm"; null -> "–" */ +export function fmm(v) { + if (!isNum(v)) return DASH; + return v === 0 ? '0 mm' : f1(v) + ' mm'; +} + +/** Positie van de marker op de min/max-balk, 0–100. Bij max == min of onbekend: 50. */ +export function markerPos(temp, min, max) { + if (!isNum(temp) || !isNum(min) || !isNum(max) || max === min) return 50; + const pos = ((temp - min) / (max - min)) * 100; + return Math.min(100, Math.max(0, pos)); +} + +/** Hoogte per staaf in px: mm / max(waarden) * maxPx, minimaal minPx. Alles 0 of null -> allemaal minPx. */ +export function barHeights(values, maxPx = 70, minPx = 2) { + const mm = values.map((v) => (isNum(v) ? v : 0)); + const top = Math.max(...mm, 0); + return mm.map((v) => (top > 0 ? Math.max(minPx, (v / top) * maxPx) : minPx)); +} + +/** WMO-weercode -> icoonnaam. Onbekend -> "cloud". */ +export function wmoIcon(code) { + for (const [codes, name] of WMO_ICONS) { + if (codes.includes(code)) return name; + } + return 'cloud'; +} + +/** "up" -> "↗ stijgend" enz.; onbekend -> "" */ +export function trendLabel(trend) { + return TRENDS[trend] || ''; +} + +/** "Vrijdag 11 september" */ +export function formatDate(date, tz) { + const s = new Intl.DateTimeFormat('nl-NL', { weekday: 'long', day: 'numeric', month: 'long', timeZone: tz }).format(date); + return cap(s); +} + +/** ISO-tijdstip -> "06:10" in de tijdzone; null -> "–" */ +export function formatTime(iso, tz) { + const d = iso instanceof Date ? iso : new Date(iso); + if (!iso || Number.isNaN(d.getTime())) return DASH; + return new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: tz }).format(d); +} + +/** "2026-09-12" -> "Za" */ +export function dayAbbr(dateStr, tz) { + const d = new Date(dateStr + 'T12:00:00Z'); + if (Number.isNaN(d.getTime())) return DASH; + const s = new Intl.DateTimeFormat('nl-NL', { weekday: 'short', timeZone: 'UTC' }).format(d); + return cap(s.replace('.', '')).slice(0, 2); +} + +/** "September" */ +export function monthName(date, tz) { + return cap(new Intl.DateTimeFormat('nl-NL', { month: 'long', timeZone: tz }).format(date)); +} + +/** Is het ISO-tijdstip ouder dan `minutes` minuten ten opzichte van `now`? Onbekend telt als verouderd. */ +export function isOlderThan(iso, now, minutes) { + if (!iso) return true; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return true; + return now.getTime() - t > minutes * 60000; +} + +/** Lokale tijd (uur, minuut, seconde) van `now` in de tijdzone. */ +function localParts(now, tz) { + const parts = new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', timeZone: tz, + }).formatToParts(now); + const get = (type) => Number(parts.find((p) => p.type === type).value); + return { h: get('hour'), m: get('minute'), s: get('second') }; +} + +/** Milliseconden tot de volgende keer dat het `hour`:`minute` is in de tijdzone (minimaal 1 s). */ +export function msUntil(now, tz, hour, minute = 0) { + const { h, m, s } = localParts(now, tz); + const target = hour * 3600 + minute * 60; + const current = h * 3600 + m * 60 + s; + let diff = target - current; + if (diff <= 0) diff += 86400; + return Math.max(1000, diff * 1000 - now.getMilliseconds()); +} diff --git a/public/icons.js b/public/icons.js new file mode 100644 index 0000000..256fedb --- /dev/null +++ b/public/icons.js @@ -0,0 +1,32 @@ +/** + * Weericonen als inline SVG-strings. viewBox 0 0 48 48, stroke 2.4, ronde uiteinden. + * Zon-delen krijgen de klasse i-sun, wolk-vullingen i-cloud, druppels/vlokken/mist i-drop. + */ + +const SUN = ''; +const SMALL_SUN = ''; +const CLOUD_LOW = ''; +const CLOUD_HIGH = ''; +const CLOUD_MID = ''; + +const ICONS = { + sun: SUN, + partly: SMALL_SUN + CLOUD_LOW, + cloud: CLOUD_MID, + fog: CLOUD_HIGH + '', + drizzle: CLOUD_HIGH + '', + rain: CLOUD_HIGH + '', + showers: '' + + '' + + '', + snow: CLOUD_HIGH + '', + thunder: CLOUD_HIGH + '', +}; + +export const ICON_NAMES = Object.keys(ICONS); + +/** SVG-markup voor een icoonnaam; onbekend -> cloud. */ +export function icon(name) { + const inner = ICONS[name] || ICONS.cloud; + return ``; +} diff --git a/tests/js/format.test.js b/tests/js/format.test.js new file mode 100644 index 0000000..4addfe7 --- /dev/null +++ b/tests/js/format.test.js @@ -0,0 +1,120 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel, + formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil, +} from '../../public/format.js'; + +const TZ = 'Europe/Berlin'; + +test('f1 uses a decimal comma and one decimal', () => { + assert.equal(f1(16.4), '16,4'); + assert.equal(f1(0), '0,0'); + assert.equal(f1(19.75), '19,8'); + assert.equal(f1(null), '–'); + assert.equal(f1(undefined), '–'); +}); + +test('fInt rounds to whole degrees', () => { + assert.equal(fInt(18.3), '18'); + assert.equal(fInt(9.5), '10'); + assert.equal(fInt(null), '–'); +}); + +test('fmm shows "0 mm" for zero', () => { + assert.equal(fmm(0), '0 mm'); + assert.equal(fmm(0.4), '0,4 mm'); + assert.equal(fmm(8.25), '8,3 mm'); + assert.equal(fmm(null), '–'); +}); + +test('markerPos interpolates and clamps', () => { + assert.ok(Math.abs(markerPos(16.4, 9.2, 19.8) - 67.92) < 0.01); + assert.equal(markerPos(5, 9.2, 19.8), 0); + assert.equal(markerPos(25, 9.2, 19.8), 100); + assert.equal(markerPos(10, 10, 10), 50); + assert.equal(markerPos(null, 1, 2), 50); +}); + +test('barHeights scales to the tallest bar with a 2px floor', () => { + const h = barHeights([1.2, 6.8, 0, 2.4]); + assert.ok(Math.abs(h[0] - 12.35) < 0.01); + assert.equal(h[1], 70); + assert.equal(h[2], 2); + assert.ok(Math.abs(h[3] - 24.7) < 0.01); + assert.deepEqual(barHeights([0, 0, 0, 0]), [2, 2, 2, 2]); + assert.deepEqual(barHeights([null, 0, undefined, 0]), [2, 2, 2, 2]); + assert.deepEqual(barHeights([null, 5]), [2, 70]); +}); + +test('wmoIcon maps every documented code', () => { + assert.equal(wmoIcon(0), 'sun'); + assert.equal(wmoIcon(1), 'partly'); + assert.equal(wmoIcon(2), 'partly'); + assert.equal(wmoIcon(3), 'cloud'); + assert.equal(wmoIcon(45), 'fog'); + assert.equal(wmoIcon(48), 'fog'); + assert.equal(wmoIcon(51), 'drizzle'); + assert.equal(wmoIcon(57), 'drizzle'); + assert.equal(wmoIcon(61), 'rain'); + assert.equal(wmoIcon(67), 'rain'); + assert.equal(wmoIcon(80), 'showers'); + assert.equal(wmoIcon(82), 'showers'); + assert.equal(wmoIcon(71), 'snow'); + assert.equal(wmoIcon(77), 'snow'); + assert.equal(wmoIcon(85), 'snow'); + assert.equal(wmoIcon(86), 'snow'); + assert.equal(wmoIcon(95), 'thunder'); + assert.equal(wmoIcon(99), 'thunder'); + assert.equal(wmoIcon(42), 'cloud'); + assert.equal(wmoIcon(null), 'cloud'); +}); + +test('trendLabel', () => { + assert.equal(trendLabel('up'), '↗ stijgend'); + assert.equal(trendLabel('down'), '↘ dalend'); + assert.equal(trendLabel('stable'), '→ stabiel'); + assert.equal(trendLabel(null), ''); +}); + +test('formatDate gives a capitalised Dutch date in the station timezone', () => { + assert.equal(formatDate(new Date('2026-09-11T12:32:05Z'), TZ), 'Vrijdag 11 september'); + // 23:30 UTC is al de volgende dag in Berlijn + assert.equal(formatDate(new Date('2026-09-11T23:30:00Z'), TZ), 'Zaterdag 12 september'); +}); + +test('formatTime uses HH:MM in the station timezone', () => { + assert.equal(formatTime('2026-09-11T06:10:00+02:00', TZ), '06:10'); + assert.equal(formatTime('2026-09-11T22:05:00Z', TZ), '00:05'); + assert.equal(formatTime(null, TZ), '–'); + assert.equal(formatTime('kapot', TZ), '–'); +}); + +test('dayAbbr gives two-letter Dutch weekday', () => { + assert.equal(dayAbbr('2026-09-12', TZ), 'Za'); + assert.equal(dayAbbr('2026-09-13', TZ), 'Zo'); + assert.equal(dayAbbr('2026-09-14', TZ), 'Ma'); + assert.equal(dayAbbr('2026-09-15', TZ), 'Di'); + assert.equal(dayAbbr('2026-09-16', TZ), 'Wo'); + assert.equal(dayAbbr('2026-09-17', TZ), 'Do'); + assert.equal(dayAbbr('2026-09-18', TZ), 'Vr'); +}); + +test('monthName', () => { + assert.equal(monthName(new Date('2026-09-11T12:00:00Z'), TZ), 'September'); +}); + +test('isOlderThan', () => { + const now = new Date('2026-09-11T12:32:00Z'); + assert.equal(isOlderThan('2026-09-11T12:10:00Z', now, 30), false); + assert.equal(isOlderThan('2026-09-11T11:50:00Z', now, 30), true); + assert.equal(isOlderThan(null, now, 30), true); +}); + +test('msUntil computes the wait until the next local time', () => { + const now = new Date('2026-09-11T12:32:05.500Z'); // 14:32:05 in Berlijn + assert.equal(msUntil(now, TZ, 0), ((24 - 14) * 3600 - 32 * 60 - 5) * 1000 - 500); + assert.equal(msUntil(now, TZ, 4), ((24 - 14 + 4) * 3600 - 32 * 60 - 5) * 1000 - 500); + assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500); + assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer'); +});