feat(ui): scope, freshness and jurist-review badges + live empty-state

Adds three dark-first chip badges in the TierBadge pattern:
- Jurisdiction (Berlin/DE/EU/International) — derived from the ELI,
  surfaced on the norms list, detail header and heatmap chips/hover.
- Freshness (aktuell/geaendert/ungeprueft) — flags norms amended
  upstream (standDate + sourceSha256); renders only when stale.
- Jurist review — marks evaluations a trusted jurist has confirmed
  (optional; the figure stands on its own).
Models gain Jurisdiction/Freshness enums, Norm.freshness/supersededNote
and Evaluation.reviewedBy/reviewedAt; HubRepository parses them from
the flow bag (ELI fallback). HeatmapPage shows a real empty-state
(hub-down vs no-evaluations) instead of a blank plot, and connect()
no longer throws on a failed probe.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-19 02:25:39 +02:00
parent 9dfa418e92
commit aabff2b140
11 changed files with 604 additions and 26 deletions

View file

@ -36,10 +36,21 @@ class HubRepository implements EvaluationRepository {
endpoint: endpoint,
authToken: settings.authToken,
);
final ok = await client.healthy().timeout(
const Duration(seconds: 4),
onTimeout: () => false,
);
// The probe must NEVER throw out of connect(): main() awaits
// this before runApp(), so any uncaught error here leaves the
// app on a blank page. A timeout maps to false; so does any
// transport error (e.g. a gRPC-web call against a plain-gRPC
// hub, or a refused connection) the app then comes up in
// 'hub-down' state and shows the empty-state hint.
bool ok;
try {
ok = await client.healthy().timeout(
const Duration(seconds: 4),
onTimeout: () => false,
);
} catch (_) {
ok = false;
}
return HubRepository._(client, settings, ok);
}
@ -97,14 +108,21 @@ class HubRepository implements EvaluationRepository {
if (normJson == null) return null;
final skmJson = _asMap(bag['skm']);
final eli = (normJson['eli'] as String?) ?? 'eli/unknown';
final norm = Norm(
eli: (normJson['eli'] as String?) ?? 'eli/unknown',
eli: eli,
title: (normJson['title'] as String?) ?? '(ohne Titel)',
jurabk: (normJson['jurabk'] as String?) ?? '',
paragraph: (normJson['paragraph'] as String?) ?? '',
jurisdiction: JurisdictionLabel.parse(
normJson['scope'] as String?,
eli: eli,
),
standDate: _parseTimestamp(event.timestamp),
sourceUrl: (normJson['source_url'] as String?) ?? '',
sourceSha256: (normJson['source_sha256'] as String?) ?? '',
freshness: _parseFreshness(normJson['freshness']),
supersededNote: normJson['superseded_note'] as String?,
paragraphs: _parseParagraphs(normJson['paragraphs']),
);
@ -138,9 +156,26 @@ class HubRepository implements EvaluationRepository {
auditEventId: event.eventId,
notes: 'Live aus Hub-Flow ${event.flowName} '
'(execution ${event.flowExecution.substring(0, 8.clamp(0, event.flowExecution.length))}).',
// Jurist sign-off is recorded by the approval, not the flow
// bag. Until the review-rewrite wires approvals in, these stay
// null (= unreviewed) unless the bag carries them explicitly.
reviewedBy: bag['reviewed_by'] as String?,
reviewedAt: DateTime.tryParse(
(bag['reviewed_at'] as String?) ?? ''),
);
}
static Freshness _parseFreshness(dynamic v) {
switch ((v as String?)?.trim().toLowerCase()) {
case 'superseded' || 'amended' || 'geändert' || 'stale':
return Freshness.superseded;
case 'unknown' || 'ungeprüft':
return Freshness.unknown;
default:
return Freshness.current;
}
}
static Map<String, dynamic>? _asMap(dynamic v) =>
v is Map<String, dynamic> ? v : null;