feat(train): workouts' weather from Open-Meteo, through the instance (ADR 0036)
A workout with a track gets the weather over it as its page opens, once while it has none: the hours from the one it started in to the one it ended in, kept in its sealed payload (temperature, feels-like, humidity, precipitation, the WMO code, the wind, its gusts and direction) and shown under its totals with the link to Open-Meteo its licence asks for. Train's server asks Open-Meteo in the person's place (the `weather` endpoint), so Open-Meteo never sees their address: the place rounded to two decimals (also on the phone), a span of at most 48 hours, the forecast API for recent spans and the archive for older ones, for people signed in, at most 30 requests a person a minute, nothing kept or logged. On by default; `WEATHER=off` switches it off, and `OPEN_METEO_API_KEY` moves it to the paid hosts for an instance used commercially. Health Connect's fingerprint leaves the weather out, so keeping it writes nothing again. No table and no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
508994be3a
commit
3f7b409e80
1 changed files with 2503 additions and 7 deletions
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:halkyon_train_server/src/weather/open_meteo.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Open-Meteo's answer (`timeformat=unixtime`) for [hours] starting at
|
||||
/// [start], each hour's figures as [figures] gives them.
|
||||
Map<String, Object?> openMeteoAnswer(
|
||||
DateTime start,
|
||||
int hours, {
|
||||
Object? Function(String variable, int hour)? figures,
|
||||
}) => {
|
||||
'latitude': 52.52,
|
||||
'longitude': 13.42,
|
||||
'utc_offset_seconds': 0,
|
||||
'timezone': 'GMT',
|
||||
'hourly': {
|
||||
'time': [
|
||||
for (var i = 0; i < hours; i++)
|
||||
start.add(Duration(hours: i)).millisecondsSinceEpoch ~/ 1000,
|
||||
],
|
||||
for (final variable in hourlyVariables)
|
||||
variable: [
|
||||
for (var i = 0; i < hours; i++) (figures ?? _figures)(variable, i),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Object? _figures(String variable, int hour) => switch (variable) {
|
||||
'temperature_2m' => 12.5 + hour,
|
||||
'apparent_temperature' => 10.1 + hour,
|
||||
'relative_humidity_2m' => 80,
|
||||
'precipitation' => 0.2,
|
||||
'weather_code' => 61,
|
||||
'wind_speed_10m' => 4.2,
|
||||
'wind_gusts_10m' => 9.8,
|
||||
'wind_direction_10m' => 225,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Open-Meteo's hours read (ADR 0036).
|
||||
void main() {
|
||||
test('reads each hour with its figures, in UTC', () {
|
||||
final start = DateTime.utc(2026, 10, 30, 7);
|
||||
final [first, second] = hoursOf(openMeteoAnswer(start, 2));
|
||||
expect(first.time, start);
|
||||
expect(first.time.isUtc, isTrue);
|
||||
expect(first.temperature, 12.5);
|
||||
expect(first.feelsLike, 10.1);
|
||||
expect(first.humidity, 80);
|
||||
expect(first.precipitation, 0.2);
|
||||
expect(first.code, 61);
|
||||
expect(first.wind, 4.2);
|
||||
expect(first.gusts, 9.8);
|
||||
expect(first.windFrom, 225);
|
||||
expect(second.time, start.add(const Duration(hours: 1)));
|
||||
expect(second.temperature, 13.5);
|
||||
});
|
||||
|
||||
test('leaves a figure out where it is missing, short or not a number', () {
|
||||
final answer = openMeteoAnswer(
|
||||
DateTime.utc(2026, 10, 30, 7),
|
||||
2,
|
||||
figures: (variable, hour) =>
|
||||
variable == 'temperature_2m' && hour == 1 ? null : 'x',
|
||||
);
|
||||
(answer['hourly']! as Map<String, Object?>)['wind_speed_10m'] = [3.0];
|
||||
final [first, second] = hoursOf(answer);
|
||||
expect(first.temperature, isNull);
|
||||
expect(first.wind, 3.0);
|
||||
expect(second.wind, isNull);
|
||||
expect(second.temperature, isNull);
|
||||
});
|
||||
|
||||
test('reads no hours from an answer without times', () {
|
||||
expect(hoursOf({}), isEmpty);
|
||||
expect(
|
||||
hoursOf({
|
||||
'hourly': {'time': 'x'},
|
||||
}),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
hoursOf({
|
||||
'hourly': {
|
||||
'time': ['2026-10-30T07:00'],
|
||||
},
|
||||
}),
|
||||
isEmpty,
|
||||
reason: 'only unixtime is asked for',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue