feat(saisie): l'horloge se qualifie, elle ne s'offre pas en réglage

« 12:00 heure de la box (Europe/London) — pas celle de cet appareil », et non
« Fuseau : Europe/Paris » : sous un champ, un intitulé de réglage laisse croire
qu'on choisit le fuseau DE CETTE échéance. Il n'y en a qu'un, il est système, et
il déplace d'un coup toutes les heures murales de l'installation — échéances,
frontière de la journée de reporting, et demain les plages tarifaires
(LM-1005-b-iii).

Affichage CONDITIONNEL : seulement quand les deux horloges divergent. C'est la
règle déjà appliquée deux fois cette semaine — ligne des ratios instantanés,
avertissement « partiel » du bilan — et sa raison tient en une phrase : un texte
permanent cesse d'être lu le jour exact où il devient vrai.

La divergence se DÉDUIT de ce que la box publie, sans base de fuseaux :
`periodStartedAt` est minuit de l'installation en epoch, donc rendu dans le
fuseau de l'appareil il tombe sur 00:00 si et seulement si les deux partagent le
même décalage. Exact pour l'instant présent, y compris les jours à 23 ou 25
heures — et c'est justement pourquoi nous avions demandé cette frontière plutôt
qu'un nom IANA.

Quatre silences testés : fuseaux identiques, échéance vide, box qui ne publie pas
la date, et l'intitulé « Fuseau : » qui ne doit jamais apparaître.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-06 09:22:33 +02:00
parent 49e92b6b11
commit 71bb26c81b
7 changed files with 137 additions and 24 deletions

View File

@ -224,10 +224,10 @@
"obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.",
"obligationEnergie": "Énergie due par jour",
"obligationEcheance": "Avant",
"obligationEcheanceAide": "Heure de l'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.",
"@obligationEcheanceAide": {
"description": "dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.",
"placeholders": { "fuseau": { "type": "String" } }
"obligationEcheanceHorloge": "{heure} heure de la box ({fuseau}) — pas celle de cet appareil.",
"@obligationEcheanceHorloge": {
"description": "QUALIFIE UNE HORLOGE, N'OFFRE PAS UN CHOIX. « Fuseau : Europe/Paris » sous un champ laisserait croire qu'on règle le fuseau DE CETTE ÉCHÉANCE : il n'y en a qu'un, c'est un réglage système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). D'où une phrase qui dit l'heure lue, pas un réglage offert.\n\nAFFICHAGE CONDITIONNEL : seulement quand le fuseau de la box diffère de celui de l'appareil. Sinon c'est un texte permanent — et un avertissement permanent cesse d'être lu le jour exact où il devient vrai.",
"placeholders": { "heure": { "type": "String" }, "fuseau": { "type": "String" } }
},
"obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close",
"@obligationNonMesurableTitre": {

View File

@ -601,11 +601,13 @@ abstract class L10n {
/// **'Avant'**
String get obligationEcheance;
/// dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.
/// QUALIFIE UNE HORLOGE, N'OFFRE PAS UN CHOIX. « Fuseau : Europe/Paris » sous un champ laisserait croire qu'on règle le fuseau DE CETTE ÉCHÉANCE : il n'y en a qu'un, c'est un réglage système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). D'où une phrase qui dit l'heure lue, pas un réglage offert.
///
/// AFFICHAGE CONDITIONNEL : seulement quand le fuseau de la box diffère de celui de l'appareil. Sinon c'est un texte permanent — et un avertissement permanent cesse d'être lu le jour exact où il devient vrai.
///
/// In fr, this message translates to:
/// **'Heure de l\'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'**
String obligationEcheanceAide(String fuseau);
/// **'{heure} heure de la box ({fuseau}) — pas celle de cet appareil.'**
String obligationEcheanceHorloge(String heure, String fuseau);
/// Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction.
///

View File

@ -349,8 +349,8 @@ class L10nFr extends L10n {
String get obligationEcheance => 'Avant';
@override
String obligationEcheanceAide(String fuseau) {
return 'Heure de l\'INSTALLATION$fuseau — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.';
String obligationEcheanceHorloge(String heure, String fuseau) {
return '$heure heure de la box ($fuseau) — pas celle de cet appareil.';
}
@override

View File

@ -82,6 +82,24 @@ class EnergyRatiosSnapshot {
return depuis.difference(debut) > const Duration(minutes: 1);
}
/// **Les deux horloges divergent-elles ?** — sans base de données de fuseaux.
///
/// [periodStartedAt] est **minuit de l'installation**, publié en epoch. Rendu dans le
/// fuseau de l'appareil, il tombe donc sur 00:00 **si et seulement si** les deux partagent
/// le même décalage. Sur `.75` en `Europe/London` vu d'un téléphone français, il tombait
/// sur 01:00 ; les deux étant passés en `Europe/Paris`, il tombe sur 00:00.
///
/// C'est exact pour l'instant présent, y compris les jours de changement d'heure, et ça
/// n'exige **rien d'autre que ce que la box publie** — le nom IANA ne donnerait pas de
/// décalage, et une base embarquée pour le recalculer serait le mauvais geste.
///
/// `false` quand la date manque : on ne conclut pas d'une absence.
bool get fuseauDiffereDeLAppareil {
final d = periodStartedAt?.toLocal();
if (d == null) return false;
return d.hour != 0 || d.minute != 0;
}
/// Vrai quand la box publie de quoi dater la journée. Sans ça, l'app ne **peut pas**
/// calculer la bonne fenêtre — et elle le dit plutôt que de prendre minuit du téléphone
/// en silence.

View File

@ -10,6 +10,7 @@ import '../../providers/load_config_provider.dart';
import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/load_hardware.dart';
import '../../models/energy_ratios_snapshot.dart';
import '../../services/telemetry_text.dart' show energieCourte;
import '../../services/load_mechanism_switch.dart';
import '../../services/load_priority.dart';
@ -2187,12 +2188,37 @@ class _Plancher extends StatelessWidget {
/// Deux des trois causes se réparent par l'installateur — rattacher un compteur, en mettre un
/// qui totalise l'énergie. La troisième est le matériel, et le dire évite de chercher un
/// réglage qui n'existe pas. C'est pour ça que la cause est affichée, jamais le seul verdict.
class _SectionObligation extends StatelessWidget {
class _SectionObligation extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _SectionObligation({required this.entry, required this.provider});
@override
State<_SectionObligation> createState() => _SectionObligationState();
}
class _SectionObligationState extends State<_SectionObligation> {
/// Le fuseau ne sert qu'à **qualifier une heure**, jamais à en proposer un réglage : il
/// est système, et il déplace d'un coup toutes les heures murales de l'installation.
/// Lu une fois, sans bloquer l'écran — absent, la ligne ne s'affiche simplement pas.
EnergyRatiosSnapshot? _ratios;
@override
void initState() {
super.initState();
final svc = context.read<NymeaService>();
_ratios = svc.energyRatios;
if (_ratios == null) {
svc.fetchEnergyRatios().then((r) {
if (mounted && r != null) setState(() => _ratios = r);
});
}
}
LoadConfigEntry get entry => widget.entry;
LoadConfigProvider get provider => widget.provider;
/// Le coût quotidien, **chiffré et non estimé** : le taux est constant depuis `+etm55`
/// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible.
String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble());
@ -2246,13 +2272,24 @@ class _SectionObligation extends StatelessWidget {
'minEnergyWhPerDay': entry.minEnergyWhPerDay,
})),
),
const SizedBox(height: 6),
// L'heure saisie est celle de la BOX, et la phrase le dit sans le fuseau — elle
// reste vraie sans lui. Le NOM du fuseau est publié par `GetEnergyRatios`
// (`timeZone`, vérifié en `+etm56` : `Europe/London`) ; il s'affichera ici quand le
// lot « bilan journalier » aura branché cet appel, et pas avant : nommer un fuseau
// qu'on n'a pas lu serait exactement la faute que cette phrase existe pour éviter.
_Aide(t.obligationEcheanceAide('')),
// ── L'HORLOGE, et seulement quand elle diverge ────────────────────────
//
// La phrase **qualifie une heure**, elle n'offre pas un choix : « Fuseau :
// Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de CETTE
// échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
// heures murales de l'installation — échéances, frontière de la journée de
// reporting, et demain les plages tarifaires (LM-1005-b-iii).
//
// Conditionnel, et c'est la règle déjà appliquée deux fois cette semaine — ligne
// des ratios instantanés, avertissement « partiel » du bilan : un texte permanent
// cesse d'être lu le jour exact où il devient vrai.
if (entry.dailyDeadline.isNotEmpty &&
(_ratios?.fuseauDiffereDeLAppareil ?? false) &&
_ratios?.timeZone != null) ...[
const SizedBox(height: 6),
_Aide(t.obligationEcheanceHorloge(
entry.dailyDeadline, _ratios!.timeZone!)),
],
if (avertir) ...[
const SizedBox(height: 12),
_Aide(

View File

@ -965,12 +965,22 @@ class NymeaService extends ChangeNotifier {
final r = await _sendRequest('NymeaEnergy.GetEnergyRatios', {});
final p = r['params'];
if (p is! Map) return null;
return EnergyRatiosSnapshot.fromJson(Map<String, dynamic>.from(p));
return _ratios = EnergyRatiosSnapshot.fromJson(Map<String, dynamic>.from(p));
} catch (_) {
return null;
}
}
EnergyRatiosSnapshot? _ratios;
/// Le dernier instantané lu, ou `null` tant que rien ne l'a demandé. Les écrans de
/// configuration s'en servent pour **qualifier une heure**, pas pour afficher un taux.
EnergyRatiosSnapshot? get energyRatios => _ratios;
/// Injecte un instantané — **tests uniquement**, même idiome que `adoptForTest`.
// ignore: use_setters_to_change_properties
void adoptRatiosForTest(EnergyRatiosSnapshot r) => _ratios = r;
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
required DateTime from,
required DateTime to,

View File

@ -15,6 +15,7 @@
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/providers/schema_provider.dart';
@ -47,7 +48,9 @@ List<Map<String, dynamic>> _charge({
}
];
Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
Widget _harness(List<Map<String, dynamic>> config,
{EnergyRatiosSnapshot? ratios}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config)),
@ -55,7 +58,10 @@ Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
value: LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: NymeaService()),
ChangeNotifierProvider.value(
value: ratios == null
? NymeaService()
: (NymeaService()..adoptRatiosForTest(ratios))),
],
child: MaterialApp(
locale: const Locale('fr'),
@ -138,12 +144,52 @@ void main() {
});
});
group('l\'échéance est à l\'heure de l\'installation', () {
testWidgets('et l\'écran le dit, plutôt que de choisir une horloge',
group('l\'horloge se QUALIFIE, elle ne s\'offre pas en réglage', () {
// « Fuseau : Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de
// CETTE échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
// heures murales de l'installation — d'où une phrase qui dit l'heure lue.
EnergyRatiosSnapshot _instantane({required bool divergent}) =>
EnergyRatiosSnapshot.fromJson({
'autonomyRate': 54.0,
'selfConsumptionRate': 52.0,
// Minuit de l'installation, en epoch. Rendu dans le fuseau de l'appareil, il tombe
// sur 00:00 si et seulement si les deux partagent le même décalage.
'periodStartedAt':
DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000 +
(divergent ? 3600 : 0),
'measuredSince': DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000,
'timeZone': divergent ? 'Europe/London' : 'Europe/Paris',
});
Widget harnessAvec(EnergyRatiosSnapshot? r,
{List<Map<String, dynamic>>? config}) =>
_harness(config ?? _charge(), ratios: r);
testWidgets('fuseaux divergents : la phrase dit l\'heure ET l\'horloge',
(tester) async {
await _poser(tester, _harness(_charge()));
expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget);
await _poser(tester, harnessAvec(_instantane(divergent: true)));
expect(find.textContaining('12:00 heure de la box (Europe/London)'),
findsOneWidget);
expect(find.textContaining('pas celle de cet appareil'), findsOneWidget);
// Ce qu'on n'écrit surtout pas : un intitulé qui ressemble à un réglage.
expect(find.textContaining('Fuseau :'), findsNothing);
});
testWidgets('fuseaux identiques : RIEN — un texte permanent cesse d\'être lu',
(tester) async {
await _poser(tester, harnessAvec(_instantane(divergent: false)));
expect(find.textContaining('heure de la box'), findsNothing);
});
testWidgets('sans échéance saisie, rien à qualifier', (tester) async {
await _poser(tester,
harnessAvec(_instantane(divergent: true), config: _charge(echeance: '')));
expect(find.textContaining('heure de la box'), findsNothing);
});
testWidgets('box qui ne publie pas la date : on se tait', (tester) async {
await _poser(tester, harnessAvec(null));
expect(find.textContaining('heure de la box'), findsNothing);
});
});
}