feat(todo): Todo's server answers a place's forecast (ADR 0033, D40)

The weather of D40 comes through the instance, as Train's workouts'
(ADR 0036, D1): Todo's server gets a `weather` endpoint, `offered` and
`forecast`, answering a place's hours for the next 16 days from
Open-Meteo for people signed in alone, the place rounded to about a
kilometre, nothing kept or logged, 30 requests a person a minute.
Refusals are `WeatherRefused` (off, invalid, tooOften, unreachable).
`WEATHER=off` and `OPEN_METEO_API_KEY` switch it as they switch Train's,
and production's compose file passes them to Todo's server too.

D40 records the developer's decisions of 10 Nov 2026 on the weather and
the agent's details (what dry, sunny, a temperature range and calm mean
over a task's hours).

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-10 14:43:15 +01:00
commit a91aa98817
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1219 additions and 5 deletions

View file

@ -19,6 +19,8 @@ import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:halkyon_todo_client/src/protocol/search/place_point.dart'
as _icyq0v3n;
import 'package:halkyon_todo_client/src/protocol/weather/forecast_hour.dart'
as _id8ywefo;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -86,6 +88,41 @@ class EndpointSearch extends _isc.EndpointRef {
);
}
/// A place's forecast from Open-Meteo (ADR 0033, D40), for those signed in:
/// the server asks in their place, so Open-Meteo never sees their address.
/// Neither the place nor the forecast is kept or logged. Refusals are
/// `WeatherRefused`.
/// {@category Endpoint}
class EndpointWeather extends _isc.EndpointRef {
EndpointWeather(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'weather';
/// Whether the instance answers forecasts.
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
'weather',
'offered',
{},
);
/// The hours of the next `Forecasts.days` days at [lat], [lon] (rounded to
/// about a kilometre). Refused where the operator switched the weather
/// off, for a place off the earth, beyond `Forecasts.perMinute` requests a
/// minute, and where Open-Meteo doesn't answer.
_ida.Future<List<_id8ywefo.ForecastHour>> forecast({
required double lat,
required double lon,
}) => caller.callServerEndpoint<List<_id8ywefo.ForecastHour>>(
'weather',
'forecast',
{
'lat': lat,
'lon': lon,
},
);
}
class Modules {
Modules(Client client) {
base = _ihk4p5t7.Caller(client);
@ -134,15 +171,21 @@ class Client extends _isc.ServerpodClientShared {
httpClientOverride: httpClientOverride,
) {
search = EndpointSearch(this);
weather = EndpointWeather(this);
modules = Modules(this);
}
late final EndpointSearch search;
late final EndpointWeather weather;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {'search': search};
Map<String, _isc.EndpointRef> get endpointRefLookup => {
'search': search,
'weather': weather,
};
@override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -15,6 +15,8 @@ import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:halkyon_todo_client/src/protocol/weather/forecast_hour.dart'
as _id8ywefo;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
@ -35,6 +37,8 @@ import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
import 'weather/forecast_hour.dart' as _i1l0g3o2;
import 'weather/weather_refused.dart' as _ieu1vbq0;
export 'comment/task_comment.dart';
export 'filter/saved_filter.dart';
export 'label/label.dart';
@ -47,6 +51,8 @@ export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'weather/forecast_hour.dart';
export 'weather/weather_refused.dart';
export 'client.dart';
export 'sync_tables.dart';
@ -673,6 +679,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ib4f8uyw.Task) {
return _ib4f8uyw.Task.fromJson(data) as T;
}
if (t == _i1l0g3o2.ForecastHour) {
return _i1l0g3o2.ForecastHour.fromJson(data) as T;
}
if (t == _ieu1vbq0.WeatherRefused) {
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
}
if (t == _isc.getType<_i68ws957.TaskComment?>()) {
return (data != null ? _i68ws957.TaskComment.fromJson(data) : null) as T;
}
@ -710,6 +722,13 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i1l0g3o2.ForecastHour?>()) {
return (data != null ? _i1l0g3o2.ForecastHour.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ieu1vbq0.WeatherRefused?>()) {
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
as T;
}
if (t == List<_iadh29yw.Place>) {
return (data as List).map((e) => deserialize<_iadh29yw.Place>(e)).toList()
as T;
@ -718,6 +737,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data as List).map((e) => deserialize<_i15767wh.Place>(e)).toList()
as T;
}
if (t == List<_id8ywefo.ForecastHour>) {
return (data as List)
.map((e) => deserialize<_id8ywefo.ForecastHour>(e))
.toList()
as T;
}
try {
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -750,6 +775,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_i1l0g3o2.ForecastHour => 'ForecastHour',
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
_ => null,
};
}
@ -791,6 +818,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'TodoSettings';
case _ib4f8uyw.Task():
return 'Task';
case _i1l0g3o2.ForecastHour():
return 'ForecastHour';
case _ieu1vbq0.WeatherRefused():
return 'WeatherRefused';
}
className = _ihk4p5t7.Protocol().getClassNameForObject(data);
if (className != null) {
@ -863,6 +894,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Task') {
return deserialize<_ib4f8uyw.Task>(data['data']);
}
if (dataClassName == 'ForecastHour') {
return deserialize<_i1l0g3o2.ForecastHour>(data['data']);
}
if (dataClassName == 'WeatherRefused') {
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
}
if (dataClassName.startsWith('halkyon_base.')) {
data['className'] = dataClassName.substring(13);
return _ihk4p5t7.Protocol().deserializeByClassName(data);

View file

@ -0,0 +1,168 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// An hour of a place's forecast (ADR 0033, D40): what Open-Meteo answers for
/// the hour starting at [time], each figure null where it has none.
abstract class ForecastHour
implements _isc.SerializableModel, _isc.ProtocolSerialization {
ForecastHour._({
required this.time,
this.temperature,
this.precipitation,
this.precipitationChance,
this.code,
this.wind,
this.gusts,
});
factory ForecastHour({
required DateTime time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
}) = _ForecastHourImpl;
factory ForecastHour.fromJson(Map<String, dynamic> jsonSerialization) {
return ForecastHour(
time: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['time']),
temperature: (jsonSerialization['temperature'] as num?)?.toDouble(),
precipitation: (jsonSerialization['precipitation'] as num?)?.toDouble(),
precipitationChance: jsonSerialization['precipitationChance'] as int?,
code: jsonSerialization['code'] as int?,
wind: (jsonSerialization['wind'] as num?)?.toDouble(),
gusts: (jsonSerialization['gusts'] as num?)?.toDouble(),
);
}
/// The hour's start, in UTC.
DateTime time;
/// At two metres, in °C.
double? temperature;
/// What is to fall over the hour (rain, showers, snow as water), in mm.
double? precipitation;
/// The chance that anything falls, in percent.
int? precipitationChance;
/// The WMO weather code (0 clear, 1–3 clouds, 45–48 fog, 51–67 drizzle and
/// rain, 71–77 snow, 80–86 showers, 95–99 thunderstorms).
int? code;
/// The wind at ten metres, in m/s, and its gusts.
double? wind;
double? gusts;
/// Returns a shallow copy of this [ForecastHour]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ForecastHour copyWith({
DateTime? time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ForecastHour',
'time': time.toJson(),
if (temperature != null) 'temperature': temperature,
if (precipitation != null) 'precipitation': precipitation,
if (precipitationChance != null)
'precipitationChance': precipitationChance,
if (code != null) 'code': code,
if (wind != null) 'wind': wind,
if (gusts != null) 'gusts': gusts,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ForecastHour',
'time': time.toJson(),
if (temperature != null) 'temperature': temperature,
if (precipitation != null) 'precipitation': precipitation,
if (precipitationChance != null)
'precipitationChance': precipitationChance,
if (code != null) 'code': code,
if (wind != null) 'wind': wind,
if (gusts != null) 'gusts': gusts,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ForecastHourImpl extends ForecastHour {
_ForecastHourImpl({
required DateTime time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
}) : super._(
time: time,
temperature: temperature,
precipitation: precipitation,
precipitationChance: precipitationChance,
code: code,
wind: wind,
gusts: gusts,
);
/// Returns a shallow copy of this [ForecastHour]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ForecastHour copyWith({
DateTime? time,
Object? temperature = _Undefined,
Object? precipitation = _Undefined,
Object? precipitationChance = _Undefined,
Object? code = _Undefined,
Object? wind = _Undefined,
Object? gusts = _Undefined,
}) {
return ForecastHour(
time: time ?? this.time,
temperature: temperature is double? ? temperature : this.temperature,
precipitation: precipitation is double?
? precipitation
: this.precipitation,
precipitationChance: precipitationChance is int?
? precipitationChance
: this.precipitationChance,
code: code is int? ? code : this.code,
wind: wind is double? ? wind : this.wind,
gusts: gusts is double? ? gusts : this.gusts,
);
}
}

View file

@ -0,0 +1,70 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why the instance answers no forecast (ADR 0033, D40): `off` where the
/// operator switched it off, `invalid` for a place off the earth, `tooOften`
/// beyond `Forecasts.perMinute` requests a minute, `unreachable` where
/// Open-Meteo didn't answer.
abstract class WeatherRefused
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
WeatherRefused._({required this.reason});
factory WeatherRefused({required String reason}) = _WeatherRefusedImpl;
factory WeatherRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return WeatherRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [WeatherRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
WeatherRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'WeatherRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'WeatherRefused',
'reason': reason,
};
}
@override
String toString() {
return 'WeatherRefused(reason: $reason)';
}
}
class _WeatherRefusedImpl extends WeatherRefused {
_WeatherRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [WeatherRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
WeatherRefused copyWith({String? reason}) {
return WeatherRefused(reason: reason ?? this.reason);
}
}