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:
parent
49e92b6b11
commit
71bb26c81b
@ -224,10 +224,10 @@
|
|||||||
"obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.",
|
"obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.",
|
||||||
"obligationEnergie": "Énergie due par jour",
|
"obligationEnergie": "Énergie due par jour",
|
||||||
"obligationEcheance": "Avant",
|
"obligationEcheance": "Avant",
|
||||||
"obligationEcheanceAide": "Heure de l'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.",
|
"obligationEcheanceHorloge": "{heure} heure de la box ({fuseau}) — pas celle de cet appareil.",
|
||||||
"@obligationEcheanceAide": {
|
"@obligationEcheanceHorloge": {
|
||||||
"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.",
|
"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": { "fuseau": { "type": "String" } }
|
"placeholders": { "heure": { "type": "String" }, "fuseau": { "type": "String" } }
|
||||||
},
|
},
|
||||||
"obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close",
|
"obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close",
|
||||||
"@obligationNonMesurableTitre": {
|
"@obligationNonMesurableTitre": {
|
||||||
|
|||||||
@ -601,11 +601,13 @@ abstract class L10n {
|
|||||||
/// **'Avant'**
|
/// **'Avant'**
|
||||||
String get obligationEcheance;
|
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:
|
/// 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.'**
|
/// **'{heure} heure de la box ({fuseau}) — pas celle de cet appareil.'**
|
||||||
String obligationEcheanceAide(String fuseau);
|
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.
|
/// 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.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -349,8 +349,8 @@ class L10nFr extends L10n {
|
|||||||
String get obligationEcheance => 'Avant';
|
String get obligationEcheance => 'Avant';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String obligationEcheanceAide(String fuseau) {
|
String obligationEcheanceHorloge(String heure, String fuseau) {
|
||||||
return 'Heure de l\'INSTALLATION$fuseau — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.';
|
return '$heure heure de la box ($fuseau) — pas celle de cet appareil.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -82,6 +82,24 @@ class EnergyRatiosSnapshot {
|
|||||||
return depuis.difference(debut) > const Duration(minutes: 1);
|
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**
|
/// 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
|
/// calculer la bonne fenêtre — et elle le dit plutôt que de prendre minuit du téléphone
|
||||||
/// en silence.
|
/// en silence.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../../providers/load_config_provider.dart';
|
|||||||
import '../../providers/load_telemetry_provider.dart';
|
import '../../providers/load_telemetry_provider.dart';
|
||||||
import '../../providers/schema_provider.dart';
|
import '../../providers/schema_provider.dart';
|
||||||
import '../../services/load_hardware.dart';
|
import '../../services/load_hardware.dart';
|
||||||
|
import '../../models/energy_ratios_snapshot.dart';
|
||||||
import '../../services/telemetry_text.dart' show energieCourte;
|
import '../../services/telemetry_text.dart' show energieCourte;
|
||||||
import '../../services/load_mechanism_switch.dart';
|
import '../../services/load_mechanism_switch.dart';
|
||||||
import '../../services/load_priority.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
|
/// 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
|
/// 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.
|
/// 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 LoadConfigEntry entry;
|
||||||
final LoadConfigProvider provider;
|
final LoadConfigProvider provider;
|
||||||
|
|
||||||
const _SectionObligation({required this.entry, required this.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`
|
/// 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.
|
/// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible.
|
||||||
String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble());
|
String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble());
|
||||||
@ -2246,13 +2272,24 @@ class _SectionObligation extends StatelessWidget {
|
|||||||
'minEnergyWhPerDay': entry.minEnergyWhPerDay,
|
'minEnergyWhPerDay': entry.minEnergyWhPerDay,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
// ── L'HORLOGE, et seulement quand elle diverge ────────────────────────
|
||||||
// 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`
|
// La phrase **qualifie une heure**, elle n'offre pas un choix : « Fuseau :
|
||||||
// (`timeZone`, vérifié en `+etm56` : `Europe/London`) ; il s'affichera ici quand le
|
// Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de CETTE
|
||||||
// lot « bilan journalier » aura branché cet appel, et pas avant : nommer un fuseau
|
// échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
|
||||||
// qu'on n'a pas lu serait exactement la faute que cette phrase existe pour éviter.
|
// heures murales de l'installation — échéances, frontière de la journée de
|
||||||
_Aide(t.obligationEcheanceAide('')),
|
// 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) ...[
|
if (avertir) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_Aide(
|
_Aide(
|
||||||
|
|||||||
@ -965,12 +965,22 @@ class NymeaService extends ChangeNotifier {
|
|||||||
final r = await _sendRequest('NymeaEnergy.GetEnergyRatios', {});
|
final r = await _sendRequest('NymeaEnergy.GetEnergyRatios', {});
|
||||||
final p = r['params'];
|
final p = r['params'];
|
||||||
if (p is! Map) return null;
|
if (p is! Map) return null;
|
||||||
return EnergyRatiosSnapshot.fromJson(Map<String, dynamic>.from(p));
|
return _ratios = EnergyRatiosSnapshot.fromJson(Map<String, dynamic>.from(p));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
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({
|
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime to,
|
required DateTime to,
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:etm_powersync_app/l10n/app_localizations.dart';
|
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_config_provider.dart';
|
||||||
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
|
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
|
||||||
import 'package:etm_powersync_app/providers/schema_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: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(
|
ChangeNotifierProvider.value(
|
||||||
value: LoadConfigProvider()..adoptForTest(config)),
|
value: LoadConfigProvider()..adoptForTest(config)),
|
||||||
@ -55,7 +58,10 @@ Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
|
|||||||
value: LoadTelemetryProvider()
|
value: LoadTelemetryProvider()
|
||||||
..adoptForTest({'degradedMode': false, 'loads': const []})),
|
..adoptForTest({'degradedMode': false, 'loads': const []})),
|
||||||
ChangeNotifierProvider.value(value: SchemaProvider()),
|
ChangeNotifierProvider.value(value: SchemaProvider()),
|
||||||
ChangeNotifierProvider.value(value: NymeaService()),
|
ChangeNotifierProvider.value(
|
||||||
|
value: ratios == null
|
||||||
|
? NymeaService()
|
||||||
|
: (NymeaService()..adoptRatiosForTest(ratios))),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
locale: const Locale('fr'),
|
locale: const Locale('fr'),
|
||||||
@ -138,12 +144,52 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('l\'échéance est à l\'heure de l\'installation', () {
|
group('l\'horloge se QUALIFIE, elle ne s\'offre pas en réglage', () {
|
||||||
testWidgets('et l\'écran le dit, plutôt que de choisir une horloge',
|
// « 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 {
|
(tester) async {
|
||||||
await _poser(tester, _harness(_charge()));
|
await _poser(tester, harnessAvec(_instantane(divergent: true)));
|
||||||
expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget);
|
expect(find.textContaining('12:00 heure de la box (Europe/London)'),
|
||||||
|
findsOneWidget);
|
||||||
expect(find.textContaining('pas celle de cet appareil'), 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user