feat: uuroverzicht per dag (temperatuur, regen, wind)
Tik op een dag in de verwachtingsstrook voor een uuroverzicht van
temperatuur, regen en wind (richting + snelheid), om de 2 uur, 12
punten per dag. Sluit na 20 seconden vanzelf of bij een tik op de
overlay zelf.
Backend: Open-Meteo levert hourly-data in dezelfde call als daily
(geen extra verzoek, dezelfde 60min-cache). Nieuwe HourlyForecastMapper
groepeert per kalenderdatum en bemonstert om het andere uur, robuust
voor de zomertijddag (23/25 uur). OpenMeteoClient::dailyForecast()
hernoemd naar forecast(), geeft nu {daily, hourly} terug. Elke dag in
de forecast-array krijgt een hourly-lijst.
Frontend: temperatuurlijn hergebruikt sparklinePath (nu ook als
sparklinePoints voor exacte uitlijning met de 12-koloms grid), regen
als kleine staafjes, windpijl (nieuw icoon) wijst waarheen de wind
waait. 20s-balk via CSS transition met reflow-restart. Servicewerker
naar v5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,9 @@ final class DashboardServiceTest extends TestCase
|
||||
self::assertFalse($p['rain']['stale']);
|
||||
self::assertCount(6, $p['forecast']);
|
||||
self::assertSame('2026-09-11', $p['forecast'][0]['date']);
|
||||
self::assertCount(12, $p['forecast'][0]['hourly']);
|
||||
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($p['forecast'][0]['hourly'][0]));
|
||||
self::assertSame('2026-09-11T00:00:00+02:00', $p['forecast'][0]['hourly'][0]['at']);
|
||||
self::assertFalse($p['forecast_stale']);
|
||||
self::assertSame([], $p['errors']);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ final class FakeSource implements WeatherSource
|
||||
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
|
||||
'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
|
||||
'pressure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure-pressure.json'), true)['body'],
|
||||
'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'],
|
||||
'forecast' => array_intersect_key(
|
||||
json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true),
|
||||
['daily' => null, 'hourly' => null],
|
||||
),
|
||||
];
|
||||
return $s;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\HourlyForecastMapper;
|
||||
|
||||
final class HourlyForecastMapperTest extends TestCase
|
||||
{
|
||||
private \DateTimeZone $tz;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tz = new \DateTimeZone('Europe/Berlin');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function hourly(): array
|
||||
{
|
||||
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['hourly'];
|
||||
}
|
||||
|
||||
public function testGroupsByDateAndSamplesEveryOtherHour(): void
|
||||
{
|
||||
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
|
||||
|
||||
self::assertSame(['2026-09-11', '2026-09-12', '2026-09-13', '2026-09-14', '2026-09-15', '2026-09-16'], array_keys($byDate));
|
||||
self::assertCount(12, $byDate['2026-09-11']);
|
||||
|
||||
$hours = array_map(
|
||||
fn(array $p) => (int) (new \DateTimeImmutable($p['at']))->format('H'),
|
||||
$byDate['2026-09-11'],
|
||||
);
|
||||
self::assertSame([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22], $hours);
|
||||
}
|
||||
|
||||
public function testPointShapeAndValues(): void
|
||||
{
|
||||
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
|
||||
$noon = $byDate['2026-09-11'][6]; // index 6 = 12:00 (0,2,4,6,8,10,12,...)
|
||||
|
||||
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($noon));
|
||||
self::assertSame('2026-09-11T12:00:00+02:00', $noon['at']);
|
||||
self::assertIsFloat($noon['temp']);
|
||||
self::assertIsFloat($noon['precip_mm']);
|
||||
self::assertIsFloat($noon['wind_kmh']);
|
||||
self::assertIsInt($noon['wind_dir']);
|
||||
}
|
||||
|
||||
public function testRainOnlyOnTheHoursThatHaveIt(): void
|
||||
{
|
||||
$byDate = HourlyForecastMapper::mapByDate($this->hourly(), $this->tz);
|
||||
// 2026-09-14 heeft 8,2 mm in de dagfixture, verdeeld over 14-16u; de bemonsterde uren zijn 0,2,4,..22.
|
||||
$rainy = array_filter($byDate['2026-09-14'], fn(array $p) => $p['precip_mm'] > 0);
|
||||
|
||||
self::assertNotEmpty($rainy);
|
||||
foreach ($rainy as $p) {
|
||||
self::assertGreaterThanOrEqual(14, (int) (new \DateTimeImmutable($p['at']))->format('H'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testDaylightSavingDayWithTwentyFiveHoursSamplesWithoutError(): void
|
||||
{
|
||||
$hourly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/hourly-dst.json'), true);
|
||||
|
||||
$byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz);
|
||||
|
||||
self::assertCount(13, $byDate['2026-10-25'], '25 punten, om de 2: index 0,2,...,24 = 13 stuks');
|
||||
}
|
||||
|
||||
public function testNullValuesStayNull(): void
|
||||
{
|
||||
$hourly = $this->hourly();
|
||||
$hourly['temperature_2m'][0] = null;
|
||||
$hourly['wind_direction_10m'][0] = 'kapot';
|
||||
|
||||
$byDate = HourlyForecastMapper::mapByDate($hourly, $this->tz);
|
||||
|
||||
self::assertNull($byDate['2026-09-11'][0]['temp']);
|
||||
self::assertNull($byDate['2026-09-11'][0]['wind_dir']);
|
||||
}
|
||||
|
||||
public function testMalformedTimestampsAreSkipped(): void
|
||||
{
|
||||
$byDate = HourlyForecastMapper::mapByDate([
|
||||
'time' => ['niet-een-datum', '2026-09-11T05:00'],
|
||||
'temperature_2m' => [1.0, 2.0],
|
||||
'precipitation' => [0.0, 0.0],
|
||||
'wind_speed_10m' => [1.0, 1.0],
|
||||
'wind_direction_10m' => [1, 1],
|
||||
], $this->tz);
|
||||
|
||||
self::assertSame(['2026-09-11'], array_keys($byDate));
|
||||
self::assertCount(1, $byDate['2026-09-11']);
|
||||
}
|
||||
|
||||
public function testMissingTimeArrayGivesEmptyResult(): void
|
||||
{
|
||||
self::assertSame([], HourlyForecastMapper::mapByDate([], $this->tz));
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,21 @@ use Weerscherm\UpstreamException;
|
||||
|
||||
final class OpenMeteoClientTest extends TestCase
|
||||
{
|
||||
public function testRequestsSixDaysWithAllDailyVariables(): void
|
||||
public function testRequestsSixDaysWithDailyAndHourlyVariables(): void
|
||||
{
|
||||
$http = new FakeHttp();
|
||||
$http->queue(200, json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true));
|
||||
|
||||
$daily = (new OpenMeteoClient($http))->dailyForecast(51.82, 6.55, 'Europe/Berlin');
|
||||
$result = (new OpenMeteoClient($http))->forecast(51.82, 6.55, 'Europe/Berlin');
|
||||
|
||||
self::assertCount(6, $daily['time']);
|
||||
self::assertCount(6, $result['daily']['time']);
|
||||
self::assertCount(144, $result['hourly']['time']);
|
||||
self::assertSame('https://api.open-meteo.com/v1/forecast', $http->requests[0]['url']);
|
||||
self::assertSame([
|
||||
'latitude' => 51.82,
|
||||
'longitude' => 6.55,
|
||||
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max',
|
||||
'hourly' => 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m',
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'forecast_days' => 6,
|
||||
], $http->requests[0]['params']);
|
||||
@@ -32,13 +34,22 @@ final class OpenMeteoClientTest extends TestCase
|
||||
{
|
||||
$http = (new FakeHttp())->queue(429, ['reason' => 'rate limit']);
|
||||
$this->expectException(UpstreamException::class);
|
||||
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
|
||||
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
|
||||
}
|
||||
|
||||
public function testMissingDailyIsUpstreamException(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, ['hourly' => []]);
|
||||
$this->expectException(UpstreamException::class);
|
||||
(new OpenMeteoClient($http))->dailyForecast(1, 2, 'UTC');
|
||||
(new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
|
||||
}
|
||||
|
||||
public function testMissingHourlyGivesEmptyHourlyBlockInsteadOfError(): void
|
||||
{
|
||||
$http = (new FakeHttp())->queue(200, ['daily' => ['time' => ['2026-09-11']]]);
|
||||
|
||||
$result = (new OpenMeteoClient($http))->forecast(1, 2, 'UTC');
|
||||
|
||||
self::assertSame([], $result['hourly']);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+804
-23
@@ -1,25 +1,806 @@
|
||||
{
|
||||
"latitude": 51.82,
|
||||
"longitude": 6.56,
|
||||
"generationtime_ms": 0.2,
|
||||
"utc_offset_seconds": 7200,
|
||||
"timezone": "Europe/Berlin",
|
||||
"timezone_abbreviation": "CEST",
|
||||
"elevation": 20.0,
|
||||
"daily_units": {
|
||||
"time": "iso8601",
|
||||
"weather_code": "wmo code",
|
||||
"temperature_2m_max": "°C",
|
||||
"temperature_2m_min": "°C",
|
||||
"precipitation_sum": "mm",
|
||||
"precipitation_probability_max": "%"
|
||||
},
|
||||
"daily": {
|
||||
"time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"],
|
||||
"weather_code": [3, 2, 0, 61, 80, 2],
|
||||
"temperature_2m_max": [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
|
||||
"temperature_2m_min": [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
|
||||
"precipitation_sum": [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
|
||||
"precipitation_probability_max": [10, 20, 5, 80, 60, 15]
|
||||
}
|
||||
"latitude": 51.82,
|
||||
"longitude": 6.56,
|
||||
"generationtime_ms": 0.2,
|
||||
"utc_offset_seconds": 7200,
|
||||
"timezone": "Europe/Berlin",
|
||||
"timezone_abbreviation": "CEST",
|
||||
"elevation": 20,
|
||||
"daily_units": {
|
||||
"time": "iso8601",
|
||||
"weather_code": "wmo code",
|
||||
"temperature_2m_max": "\u00b0C",
|
||||
"temperature_2m_min": "\u00b0C",
|
||||
"precipitation_sum": "mm",
|
||||
"precipitation_probability_max": "%"
|
||||
},
|
||||
"daily": {
|
||||
"time": [
|
||||
"2026-09-11",
|
||||
"2026-09-12",
|
||||
"2026-09-13",
|
||||
"2026-09-14",
|
||||
"2026-09-15",
|
||||
"2026-09-16"
|
||||
],
|
||||
"weather_code": [
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
61,
|
||||
80,
|
||||
2
|
||||
],
|
||||
"temperature_2m_max": [
|
||||
17.9,
|
||||
18.3,
|
||||
20.1,
|
||||
17.4,
|
||||
15.2,
|
||||
16
|
||||
],
|
||||
"temperature_2m_min": [
|
||||
11,
|
||||
10.1,
|
||||
11.3,
|
||||
12,
|
||||
9.4,
|
||||
8.1
|
||||
],
|
||||
"precipitation_sum": [
|
||||
0,
|
||||
0.4,
|
||||
0,
|
||||
8.2,
|
||||
3.1,
|
||||
0.2
|
||||
],
|
||||
"precipitation_probability_max": [
|
||||
10,
|
||||
20,
|
||||
5,
|
||||
80,
|
||||
60,
|
||||
15
|
||||
]
|
||||
},
|
||||
"hourly_units": {
|
||||
"time": "iso8601",
|
||||
"temperature_2m": "\u00b0C",
|
||||
"precipitation": "mm",
|
||||
"wind_speed_10m": "km/h",
|
||||
"wind_direction_10m": "\u00b0"
|
||||
},
|
||||
"hourly": {
|
||||
"time": [
|
||||
"2026-09-11T00:00",
|
||||
"2026-09-11T01:00",
|
||||
"2026-09-11T02:00",
|
||||
"2026-09-11T03:00",
|
||||
"2026-09-11T04:00",
|
||||
"2026-09-11T05:00",
|
||||
"2026-09-11T06:00",
|
||||
"2026-09-11T07:00",
|
||||
"2026-09-11T08:00",
|
||||
"2026-09-11T09:00",
|
||||
"2026-09-11T10:00",
|
||||
"2026-09-11T11:00",
|
||||
"2026-09-11T12:00",
|
||||
"2026-09-11T13:00",
|
||||
"2026-09-11T14:00",
|
||||
"2026-09-11T15:00",
|
||||
"2026-09-11T16:00",
|
||||
"2026-09-11T17:00",
|
||||
"2026-09-11T18:00",
|
||||
"2026-09-11T19:00",
|
||||
"2026-09-11T20:00",
|
||||
"2026-09-11T21:00",
|
||||
"2026-09-11T22:00",
|
||||
"2026-09-11T23:00",
|
||||
"2026-09-12T00:00",
|
||||
"2026-09-12T01:00",
|
||||
"2026-09-12T02:00",
|
||||
"2026-09-12T03:00",
|
||||
"2026-09-12T04:00",
|
||||
"2026-09-12T05:00",
|
||||
"2026-09-12T06:00",
|
||||
"2026-09-12T07:00",
|
||||
"2026-09-12T08:00",
|
||||
"2026-09-12T09:00",
|
||||
"2026-09-12T10:00",
|
||||
"2026-09-12T11:00",
|
||||
"2026-09-12T12:00",
|
||||
"2026-09-12T13:00",
|
||||
"2026-09-12T14:00",
|
||||
"2026-09-12T15:00",
|
||||
"2026-09-12T16:00",
|
||||
"2026-09-12T17:00",
|
||||
"2026-09-12T18:00",
|
||||
"2026-09-12T19:00",
|
||||
"2026-09-12T20:00",
|
||||
"2026-09-12T21:00",
|
||||
"2026-09-12T22:00",
|
||||
"2026-09-12T23:00",
|
||||
"2026-09-13T00:00",
|
||||
"2026-09-13T01:00",
|
||||
"2026-09-13T02:00",
|
||||
"2026-09-13T03:00",
|
||||
"2026-09-13T04:00",
|
||||
"2026-09-13T05:00",
|
||||
"2026-09-13T06:00",
|
||||
"2026-09-13T07:00",
|
||||
"2026-09-13T08:00",
|
||||
"2026-09-13T09:00",
|
||||
"2026-09-13T10:00",
|
||||
"2026-09-13T11:00",
|
||||
"2026-09-13T12:00",
|
||||
"2026-09-13T13:00",
|
||||
"2026-09-13T14:00",
|
||||
"2026-09-13T15:00",
|
||||
"2026-09-13T16:00",
|
||||
"2026-09-13T17:00",
|
||||
"2026-09-13T18:00",
|
||||
"2026-09-13T19:00",
|
||||
"2026-09-13T20:00",
|
||||
"2026-09-13T21:00",
|
||||
"2026-09-13T22:00",
|
||||
"2026-09-13T23:00",
|
||||
"2026-09-14T00:00",
|
||||
"2026-09-14T01:00",
|
||||
"2026-09-14T02:00",
|
||||
"2026-09-14T03:00",
|
||||
"2026-09-14T04:00",
|
||||
"2026-09-14T05:00",
|
||||
"2026-09-14T06:00",
|
||||
"2026-09-14T07:00",
|
||||
"2026-09-14T08:00",
|
||||
"2026-09-14T09:00",
|
||||
"2026-09-14T10:00",
|
||||
"2026-09-14T11:00",
|
||||
"2026-09-14T12:00",
|
||||
"2026-09-14T13:00",
|
||||
"2026-09-14T14:00",
|
||||
"2026-09-14T15:00",
|
||||
"2026-09-14T16:00",
|
||||
"2026-09-14T17:00",
|
||||
"2026-09-14T18:00",
|
||||
"2026-09-14T19:00",
|
||||
"2026-09-14T20:00",
|
||||
"2026-09-14T21:00",
|
||||
"2026-09-14T22:00",
|
||||
"2026-09-14T23:00",
|
||||
"2026-09-15T00:00",
|
||||
"2026-09-15T01:00",
|
||||
"2026-09-15T02:00",
|
||||
"2026-09-15T03:00",
|
||||
"2026-09-15T04:00",
|
||||
"2026-09-15T05:00",
|
||||
"2026-09-15T06:00",
|
||||
"2026-09-15T07:00",
|
||||
"2026-09-15T08:00",
|
||||
"2026-09-15T09:00",
|
||||
"2026-09-15T10:00",
|
||||
"2026-09-15T11:00",
|
||||
"2026-09-15T12:00",
|
||||
"2026-09-15T13:00",
|
||||
"2026-09-15T14:00",
|
||||
"2026-09-15T15:00",
|
||||
"2026-09-15T16:00",
|
||||
"2026-09-15T17:00",
|
||||
"2026-09-15T18:00",
|
||||
"2026-09-15T19:00",
|
||||
"2026-09-15T20:00",
|
||||
"2026-09-15T21:00",
|
||||
"2026-09-15T22:00",
|
||||
"2026-09-15T23:00",
|
||||
"2026-09-16T00:00",
|
||||
"2026-09-16T01:00",
|
||||
"2026-09-16T02:00",
|
||||
"2026-09-16T03:00",
|
||||
"2026-09-16T04:00",
|
||||
"2026-09-16T05:00",
|
||||
"2026-09-16T06:00",
|
||||
"2026-09-16T07:00",
|
||||
"2026-09-16T08:00",
|
||||
"2026-09-16T09:00",
|
||||
"2026-09-16T10:00",
|
||||
"2026-09-16T11:00",
|
||||
"2026-09-16T12:00",
|
||||
"2026-09-16T13:00",
|
||||
"2026-09-16T14:00",
|
||||
"2026-09-16T15:00",
|
||||
"2026-09-16T16:00",
|
||||
"2026-09-16T17:00",
|
||||
"2026-09-16T18:00",
|
||||
"2026-09-16T19:00",
|
||||
"2026-09-16T20:00",
|
||||
"2026-09-16T21:00",
|
||||
"2026-09-16T22:00",
|
||||
"2026-09-16T23:00"
|
||||
],
|
||||
"temperature_2m": [
|
||||
10.3,
|
||||
9.7,
|
||||
9.3,
|
||||
9.2,
|
||||
9.3,
|
||||
9.7,
|
||||
10.3,
|
||||
11,
|
||||
11.9,
|
||||
12.8,
|
||||
13.7,
|
||||
14.6,
|
||||
15.3,
|
||||
15.9,
|
||||
16.3,
|
||||
16.4,
|
||||
16.3,
|
||||
15.9,
|
||||
15.3,
|
||||
14.6,
|
||||
13.7,
|
||||
12.8,
|
||||
11.9,
|
||||
11,
|
||||
11.3,
|
||||
10.6,
|
||||
10.2,
|
||||
10.1,
|
||||
10.2,
|
||||
10.6,
|
||||
11.3,
|
||||
12.2,
|
||||
13.1,
|
||||
14.2,
|
||||
15.3,
|
||||
16.3,
|
||||
17.1,
|
||||
17.8,
|
||||
18.2,
|
||||
18.3,
|
||||
18.2,
|
||||
17.8,
|
||||
17.1,
|
||||
16.3,
|
||||
15.3,
|
||||
14.2,
|
||||
13.1,
|
||||
12.2,
|
||||
12.6,
|
||||
11.9,
|
||||
11.4,
|
||||
11.3,
|
||||
11.4,
|
||||
11.9,
|
||||
12.6,
|
||||
13.5,
|
||||
14.6,
|
||||
15.7,
|
||||
16.8,
|
||||
17.9,
|
||||
18.8,
|
||||
19.5,
|
||||
20,
|
||||
20.1,
|
||||
20,
|
||||
19.5,
|
||||
18.8,
|
||||
17.9,
|
||||
16.8,
|
||||
15.7,
|
||||
14.6,
|
||||
13.5,
|
||||
12.8,
|
||||
12.4,
|
||||
12.1,
|
||||
12,
|
||||
12.1,
|
||||
12.4,
|
||||
12.8,
|
||||
13.4,
|
||||
14,
|
||||
14.7,
|
||||
15.4,
|
||||
16.1,
|
||||
16.6,
|
||||
17,
|
||||
17.3,
|
||||
17.4,
|
||||
17.3,
|
||||
17,
|
||||
16.6,
|
||||
16.1,
|
||||
15.4,
|
||||
14.7,
|
||||
14,
|
||||
13.4,
|
||||
10.2,
|
||||
9.8,
|
||||
9.5,
|
||||
9.4,
|
||||
9.5,
|
||||
9.8,
|
||||
10.2,
|
||||
10.9,
|
||||
11.5,
|
||||
12.3,
|
||||
13.1,
|
||||
13.8,
|
||||
14.4,
|
||||
14.8,
|
||||
15.1,
|
||||
15.2,
|
||||
15.1,
|
||||
14.8,
|
||||
14.4,
|
||||
13.8,
|
||||
13.1,
|
||||
12.3,
|
||||
11.5,
|
||||
10.9,
|
||||
9.3,
|
||||
8.6,
|
||||
8.2,
|
||||
8.1,
|
||||
8.2,
|
||||
8.6,
|
||||
9.3,
|
||||
10.1,
|
||||
11,
|
||||
12.1,
|
||||
13.1,
|
||||
14,
|
||||
14.8,
|
||||
15.5,
|
||||
15.9,
|
||||
16,
|
||||
15.9,
|
||||
15.5,
|
||||
14.8,
|
||||
14,
|
||||
13.1,
|
||||
12.1,
|
||||
11,
|
||||
10.1
|
||||
],
|
||||
"precipitation": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2.7,
|
||||
2.7,
|
||||
2.7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.1,
|
||||
0.1,
|
||||
0.1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wind_speed_10m": [
|
||||
6,
|
||||
6.8,
|
||||
7.5,
|
||||
8.1,
|
||||
8.6,
|
||||
8.9,
|
||||
9,
|
||||
8.9,
|
||||
8.6,
|
||||
8.1,
|
||||
7.5,
|
||||
6.8,
|
||||
6,
|
||||
5.2,
|
||||
4.5,
|
||||
3.9,
|
||||
3.4,
|
||||
3.1,
|
||||
3,
|
||||
3.1,
|
||||
3.4,
|
||||
3.9,
|
||||
4.5,
|
||||
5.2,
|
||||
8.1,
|
||||
8.6,
|
||||
8.9,
|
||||
9,
|
||||
8.9,
|
||||
8.6,
|
||||
8.1,
|
||||
7.5,
|
||||
6.8,
|
||||
6,
|
||||
5.2,
|
||||
4.5,
|
||||
3.9,
|
||||
3.4,
|
||||
3.1,
|
||||
3,
|
||||
3.1,
|
||||
3.4,
|
||||
3.9,
|
||||
4.5,
|
||||
5.2,
|
||||
6,
|
||||
6.8,
|
||||
7.5,
|
||||
9,
|
||||
8.9,
|
||||
8.6,
|
||||
8.1,
|
||||
7.5,
|
||||
6.8,
|
||||
6,
|
||||
5.2,
|
||||
4.5,
|
||||
3.9,
|
||||
3.4,
|
||||
3.1,
|
||||
3,
|
||||
3.1,
|
||||
3.4,
|
||||
3.9,
|
||||
4.5,
|
||||
5.2,
|
||||
6,
|
||||
6.8,
|
||||
7.5,
|
||||
8.1,
|
||||
8.6,
|
||||
8.9,
|
||||
8.1,
|
||||
7.5,
|
||||
6.8,
|
||||
6,
|
||||
5.2,
|
||||
4.5,
|
||||
3.9,
|
||||
3.4,
|
||||
3.1,
|
||||
3,
|
||||
3.1,
|
||||
3.4,
|
||||
3.9,
|
||||
4.5,
|
||||
5.2,
|
||||
6,
|
||||
6.8,
|
||||
7.5,
|
||||
8.1,
|
||||
8.6,
|
||||
8.9,
|
||||
9,
|
||||
8.9,
|
||||
8.6,
|
||||
6,
|
||||
5.2,
|
||||
4.5,
|
||||
3.9,
|
||||
3.4,
|
||||
3.1,
|
||||
3,
|
||||
3.1,
|
||||
3.4,
|
||||
3.9,
|
||||
4.5,
|
||||
5.2,
|
||||
6,
|
||||
6.8,
|
||||
7.5,
|
||||
8.1,
|
||||
8.6,
|
||||
8.9,
|
||||
9,
|
||||
8.9,
|
||||
8.6,
|
||||
8.1,
|
||||
7.5,
|
||||
6.8,
|
||||
3.9,
|
||||
3.4,
|
||||
3.1,
|
||||
3,
|
||||
3.1,
|
||||
3.4,
|
||||
3.9,
|
||||
4.5,
|
||||
5.2,
|
||||
6,
|
||||
6.8,
|
||||
7.5,
|
||||
8.1,
|
||||
8.6,
|
||||
8.9,
|
||||
9,
|
||||
8.9,
|
||||
8.6,
|
||||
8.1,
|
||||
7.5,
|
||||
6.8,
|
||||
6,
|
||||
5.2,
|
||||
4.5
|
||||
],
|
||||
"wind_direction_10m": [
|
||||
0,
|
||||
7,
|
||||
14,
|
||||
21,
|
||||
28,
|
||||
35,
|
||||
42,
|
||||
49,
|
||||
56,
|
||||
63,
|
||||
70,
|
||||
77,
|
||||
84,
|
||||
91,
|
||||
98,
|
||||
105,
|
||||
112,
|
||||
119,
|
||||
126,
|
||||
133,
|
||||
140,
|
||||
147,
|
||||
154,
|
||||
161,
|
||||
40,
|
||||
47,
|
||||
54,
|
||||
61,
|
||||
68,
|
||||
75,
|
||||
82,
|
||||
89,
|
||||
96,
|
||||
103,
|
||||
110,
|
||||
117,
|
||||
124,
|
||||
131,
|
||||
138,
|
||||
145,
|
||||
152,
|
||||
159,
|
||||
166,
|
||||
173,
|
||||
180,
|
||||
187,
|
||||
194,
|
||||
201,
|
||||
80,
|
||||
87,
|
||||
94,
|
||||
101,
|
||||
108,
|
||||
115,
|
||||
122,
|
||||
129,
|
||||
136,
|
||||
143,
|
||||
150,
|
||||
157,
|
||||
164,
|
||||
171,
|
||||
178,
|
||||
185,
|
||||
192,
|
||||
199,
|
||||
206,
|
||||
213,
|
||||
220,
|
||||
227,
|
||||
234,
|
||||
241,
|
||||
120,
|
||||
127,
|
||||
134,
|
||||
141,
|
||||
148,
|
||||
155,
|
||||
162,
|
||||
169,
|
||||
176,
|
||||
183,
|
||||
190,
|
||||
197,
|
||||
204,
|
||||
211,
|
||||
218,
|
||||
225,
|
||||
232,
|
||||
239,
|
||||
246,
|
||||
253,
|
||||
260,
|
||||
267,
|
||||
274,
|
||||
281,
|
||||
160,
|
||||
167,
|
||||
174,
|
||||
181,
|
||||
188,
|
||||
195,
|
||||
202,
|
||||
209,
|
||||
216,
|
||||
223,
|
||||
230,
|
||||
237,
|
||||
244,
|
||||
251,
|
||||
258,
|
||||
265,
|
||||
272,
|
||||
279,
|
||||
286,
|
||||
293,
|
||||
300,
|
||||
307,
|
||||
314,
|
||||
321,
|
||||
200,
|
||||
207,
|
||||
214,
|
||||
221,
|
||||
228,
|
||||
235,
|
||||
242,
|
||||
249,
|
||||
256,
|
||||
263,
|
||||
270,
|
||||
277,
|
||||
284,
|
||||
291,
|
||||
298,
|
||||
305,
|
||||
312,
|
||||
319,
|
||||
326,
|
||||
333,
|
||||
340,
|
||||
347,
|
||||
354,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+137
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"time": [
|
||||
"2026-10-25T00:00",
|
||||
"2026-10-25T01:00",
|
||||
"2026-10-25T02:00",
|
||||
"2026-10-25T03:00",
|
||||
"2026-10-25T04:00",
|
||||
"2026-10-25T05:00",
|
||||
"2026-10-25T06:00",
|
||||
"2026-10-25T07:00",
|
||||
"2026-10-25T08:00",
|
||||
"2026-10-25T09:00",
|
||||
"2026-10-25T10:00",
|
||||
"2026-10-25T11:00",
|
||||
"2026-10-25T12:00",
|
||||
"2026-10-25T13:00",
|
||||
"2026-10-25T14:00",
|
||||
"2026-10-25T15:00",
|
||||
"2026-10-25T16:00",
|
||||
"2026-10-25T17:00",
|
||||
"2026-10-25T18:00",
|
||||
"2026-10-25T19:00",
|
||||
"2026-10-25T20:00",
|
||||
"2026-10-25T21:00",
|
||||
"2026-10-25T22:00",
|
||||
"2026-10-25T23:00",
|
||||
"2026-10-25T02:00"
|
||||
],
|
||||
"temperature_2m": [
|
||||
10,
|
||||
10.1,
|
||||
10.2,
|
||||
10.3,
|
||||
10.4,
|
||||
10.5,
|
||||
10.6,
|
||||
10.7,
|
||||
10.8,
|
||||
10.9,
|
||||
11,
|
||||
11.1,
|
||||
11.2,
|
||||
11.3,
|
||||
11.4,
|
||||
11.5,
|
||||
11.6,
|
||||
11.7,
|
||||
11.8,
|
||||
11.9,
|
||||
12,
|
||||
12.1,
|
||||
12.2,
|
||||
12.3,
|
||||
12.4
|
||||
],
|
||||
"precipitation": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wind_speed_10m": [
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5
|
||||
],
|
||||
"wind_direction_10m": [
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180,
|
||||
180
|
||||
]
|
||||
}
|
||||
+37
-1
@@ -119,7 +119,10 @@ test('msUntil computes the wait until the next local time', () => {
|
||||
assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer');
|
||||
});
|
||||
|
||||
import { co2Level, fPct, fHpa, seriesRange, sparklinePath } from '../../public/format.js';
|
||||
import {
|
||||
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
||||
windArrowDeg, fKmh, formatHour, formatFullDate,
|
||||
} from '../../public/format.js';
|
||||
|
||||
test('co2Level uses the Netatmo thresholds', () => {
|
||||
assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
|
||||
@@ -151,3 +154,36 @@ test('sparklinePath scales points into the box', () => {
|
||||
assert.equal(sparklinePath([1000], 100, 50), '');
|
||||
assert.equal(sparklinePath([], 100, 50), '');
|
||||
});
|
||||
|
||||
test('sparklinePoints gives x for every index and null y for unknown values', () => {
|
||||
const points = sparklinePoints([1000, null, 1010], 100, 50, 0);
|
||||
assert.deepEqual(points.map((p) => p.x), [0, 50, 100]);
|
||||
assert.equal(points[0].y, 50);
|
||||
assert.equal(points[1].y, null);
|
||||
assert.equal(points[2].y, 0);
|
||||
});
|
||||
|
||||
test('windArrowDeg rotates 180 degrees so the arrow points where the wind blows to', () => {
|
||||
assert.equal(windArrowDeg(0), 180);
|
||||
assert.equal(windArrowDeg(270), 90);
|
||||
assert.equal(windArrowDeg(200), 20);
|
||||
assert.equal(windArrowDeg(null), null);
|
||||
});
|
||||
|
||||
test('fKmh rounds to whole km/u', () => {
|
||||
assert.equal(fKmh(8.4), '8 km/u');
|
||||
assert.equal(fKmh(8.6), '9 km/u');
|
||||
assert.equal(fKmh(null), '–');
|
||||
});
|
||||
|
||||
test('formatHour gives a two-digit hour in the station timezone', () => {
|
||||
assert.equal(formatHour('2026-09-14T00:00:00+02:00', TZ), '00');
|
||||
assert.equal(formatHour('2026-09-14T14:00:00+02:00', TZ), '14');
|
||||
assert.equal(formatHour('2026-09-13T23:00:00Z', TZ), '01', '23:00 UTC is 01:00 de volgende dag in Berlijn');
|
||||
assert.equal(formatHour(null, TZ), '–');
|
||||
});
|
||||
|
||||
test('formatFullDate matches formatDate for the same calendar date', () => {
|
||||
assert.equal(formatFullDate('2026-09-14', TZ), 'Maandag 14 september');
|
||||
assert.equal(formatFullDate('kapot', TZ), '–');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user