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:
Tizian 「ティツィアーン」 2026-11-02 13:59:41 +01:00
commit 3f7b409e80
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -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',
);
});
}