feat: format.js pure helpers with node tests, weather icons
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "weerscherm",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test \"tests/js/**/*.test.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());
|
||||
}
|
||||
@@ -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 = '<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>';
|
||||
const SMALL_SUN = '<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>';
|
||||
const CLOUD_LOW = '<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"/>';
|
||||
const CLOUD_HIGH = '<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"/>';
|
||||
const CLOUD_MID = '<path class="i-cloud" d="M15 34h19a7 7 0 0 0 .8-13.96A10 10 0 0 0 15.3 22 6 6 0 0 0 15 34z"/>';
|
||||
|
||||
const ICONS = {
|
||||
sun: SUN,
|
||||
partly: SMALL_SUN + CLOUD_LOW,
|
||||
cloud: CLOUD_MID,
|
||||
fog: CLOUD_HIGH + '<path class="i-drop" d="M14 36h20M18 42h14"/>',
|
||||
drizzle: CLOUD_HIGH + '<path class="i-drop" d="M18 35l-1 2.5M26 35l-1 2.5M34 35l-1 2.5"/>',
|
||||
rain: CLOUD_HIGH + '<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"/>',
|
||||
snow: CLOUD_HIGH + '<path class="i-drop" d="M17 35v6M14.4 36.5l5.2 3M14.4 39.5l5.2-3M25 35v6M22.4 36.5l5.2 3M22.4 39.5l5.2-3M33 35v6M30.4 36.5l5.2 3M30.4 39.5l5.2-3"/>',
|
||||
thunder: CLOUD_HIGH + '<path class="i-sun" d="M26 31l-5 8h6l-3 6"/>',
|
||||
};
|
||||
|
||||
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 `<svg class="ic" viewBox="0 0 48 48" aria-hidden="true">${inner}</svg>`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user