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:
parent
85a022af98
commit
a91aa98817
4 changed files with 1219 additions and 5 deletions
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue