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

@ -26,6 +26,7 @@ class Fixtures {
title: 'Anzeigepflicht',
jurabk: 'GewO',
paragraph: '§ 14',
jurisdiction: Jurisdiction.deutschland,
standDate: DateTime.utc(2025, 12, 1),
sourceUrl: 'https://www.gesetze-im-internet.de/gewo/__14.html',
sourceSha256: 'demo-${'a' * 60}',
@ -104,6 +105,7 @@ class Fixtures {
title: 'Kassensicherungsverordnung',
jurabk: 'KassenSichV',
paragraph: 'gesamt',
jurisdiction: Jurisdiction.deutschland,
standDate: DateTime.utc(2025, 11, 15),
sourceUrl:
'https://www.gesetze-im-internet.de/kassensichv/index.html',
@ -177,6 +179,8 @@ class Fixtures {
),
],
auditEventId: 'demo-evt-kassensichv',
reviewedBy: 'RA Dr. M. Kessler',
reviewedAt: DateTime.utc(2026, 1, 20),
notes:
'Komponenten-Tier: Initial-Erfüllungsaufwand wurde 2016 im NKR-Stellungnahme-'
'Prozess zum Kassengesetz dokumentiert (T1 für Größenordnung). '
@ -197,6 +201,7 @@ class Fixtures {
title: 'Verzeichnis von Verarbeitungstätigkeiten',
jurabk: 'DSGVO',
paragraph: 'Art. 30',
jurisdiction: Jurisdiction.eu,
standDate: DateTime.utc(2025, 10, 1),
sourceUrl: 'https://eur-lex.europa.eu/eli/reg/2016/679',
sourceSha256: 'demo-${'c' * 60}',
@ -277,6 +282,8 @@ class Fixtures {
),
],
auditEventId: 'demo-evt-dsgvo-30',
reviewedBy: 'Prof. Dr. A. Lindner',
reviewedAt: DateTime.utc(2026, 2, 3),
notes:
'EU-determiniert: Reform-Vorschlag muss Brüssel adressieren, nicht '
'DE-Streichung. Komponenten-Tier: P (Fallzahl Verantwortliche) '
@ -299,6 +306,7 @@ class Fixtures {
title: 'Bauantrag',
jurabk: 'BauO Bln',
paragraph: '§ 60 ff',
jurisdiction: Jurisdiction.berlin,
standDate: DateTime.utc(2025, 9, 1),
sourceUrl:
'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60',
@ -363,6 +371,8 @@ class Fixtures {
),
],
auditEventId: 'demo-evt-berlinbauo-60',
reviewedBy: 'RAin S. Brandt',
reviewedAt: DateTime.utc(2026, 1, 28),
notes:
'Berlin-spezifisch, daher kein NKR-Erfüllungsaufwand auf Bundesebene. '
'P aus Berliner Bauaufsichts-Statistik (T2 Handwerkskammer Berlin); '
@ -385,9 +395,14 @@ class Fixtures {
title: 'Dokumentationspflichten',
jurabk: 'MiLoG',
paragraph: '§ 17',
jurisdiction: Jurisdiction.deutschland,
standDate: DateTime.utc(2025, 11, 1),
sourceUrl: 'https://www.gesetze-im-internet.de/milog/__17.html',
sourceSha256: 'demo-${'e' * 60}',
freshness: Freshness.superseded,
supersededNote: 'BEG IV hat die Dokumentationspflichten zum '
'1.1.2026 entschärft — diese Auswertung bildet die Fassung '
'davor ab und ist neu zu bewerten.',
paragraphs: const [
NormParagraph(
number: '(1)',

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;

View file

@ -51,6 +51,138 @@ extension EvidenceTierLabel on EvidenceTier {
};
}
/// Geltungsbereich the geographic/legal scope a norm belongs to.
/// Nested by widening reach: Berlin Deutschland EU Welt.
/// Every norm carries one so the UI can flag, at a glance, *which*
/// reform lever a figure speaks to (Bezirk/Senat, Bund, Brüssel,
/// or völkerrechtlich) see Studie §5.3.
enum Jurisdiction {
/// Landesrecht Berlin (BauO Bln, ASOG, ) Senat/Bezirk lever.
berlin,
/// Bundesrecht (GewO, MiLoG, KassenSichV) NKR/Bundestag lever.
deutschland,
/// EU-Recht (DSGVO, REFIT) Reform-Hebel liegt in Brüssel.
eu,
/// Völker-/Welthandelsrecht (WTO, OECD, UN) international.
international,
}
extension JurisdictionLabel on Jurisdiction {
/// Kompakt-Code für Chips und enge Layouts.
String get short => switch (this) {
Jurisdiction.berlin => 'BE',
Jurisdiction.deutschland => 'DE',
Jurisdiction.eu => 'EU',
Jurisdiction.international => 'INT',
};
/// Voll ausgeschriebener Name.
String get label => switch (this) {
Jurisdiction.berlin => 'Berlin',
Jurisdiction.deutschland => 'Deutschland',
Jurisdiction.eu => 'EU',
Jurisdiction.international => 'International',
};
/// Rechtsebene für Tooltip und Legend.
String get level => switch (this) {
Jurisdiction.berlin => 'Landesrecht Berlin',
Jurisdiction.deutschland => 'Bundesrecht',
Jurisdiction.eu => 'Recht der Europäischen Union',
Jurisdiction.international => 'Völker-/Welthandelsrecht',
};
/// Wo der Reform-Hebel ansetzt die handlungsleitende Aussage.
String get reformLever => switch (this) {
Jurisdiction.berlin =>
'Reform-Hebel: Senat / Bezirk (Land Berlin)',
Jurisdiction.deutschland =>
'Reform-Hebel: Bundestag / NKR (Bund)',
Jurisdiction.eu =>
'Reform-Hebel: Brüssel — DE-Streichung allein wirkt nicht',
Jurisdiction.international =>
'Reform-Hebel: völkerrechtlich (WTO / OECD / UN)',
};
/// Ableitung aus der ELI/CELEX-Kennung, wenn kein Feld gesetzt
/// ist (Hub-Live-Modus liefert die ELI, nicht immer den Scope).
/// eli/eu/ eu
/// eli/land/be/ berlin
/// eli/bund/ deutschland
/// eli/int|un|wto/ international
static Jurisdiction fromEli(String eli) {
final e = eli.toLowerCase();
if (e.contains('/eu/') || e.startsWith('eli/eu') || e.contains('celex')) {
return Jurisdiction.eu;
}
if (e.contains('/land/be') || e.contains('/land/berlin') ||
e.contains('/be/')) {
return Jurisdiction.berlin;
}
if (e.contains('/int/') || e.contains('/un/') || e.contains('/wto/') ||
e.contains('/oecd/')) {
return Jurisdiction.international;
}
return Jurisdiction.deutschland;
}
/// Parse a serialized scope string from a hub flow bag, falling
/// back to ELI-derivation when absent or unrecognized.
static Jurisdiction parse(String? raw, {required String eli}) {
switch (raw?.trim().toLowerCase()) {
case 'berlin' || 'be' || 'land-berlin':
return Jurisdiction.berlin;
case 'deutschland' || 'de' || 'bund' || 'bundesrecht':
return Jurisdiction.deutschland;
case 'eu' || 'europa' || 'union':
return Jurisdiction.eu;
case 'international' || 'int' || 'welt' || 'global':
return Jurisdiction.international;
default:
return fromEli(eli);
}
}
}
/// Aktualität einer Norm-Fassung relativ zu unserer Datenbasis.
/// Quelle der Wahrheit: `Norm.sourceSha256` gegen die aktuell
/// veröffentlichte Quelle + `standDate`. Surfaced als Badge, damit
/// eine veraltete Auswertung sofort erkennbar ist (Studie §5.3).
enum Freshness {
/// Unsere Fassung == aktuell veröffentlichter Stand.
current,
/// Upstream geändert/aufgehoben unsere Auswertung ist veraltet.
superseded,
/// Noch nicht gegen die Quelle abgeglichen.
unknown,
}
extension FreshnessLabel on Freshness {
String get label => switch (this) {
Freshness.current => 'aktuell',
Freshness.superseded => 'geändert',
Freshness.unknown => 'ungeprüft',
};
String get description => switch (this) {
Freshness.current =>
'Fassung entspricht dem aktuell veröffentlichten Stand.',
Freshness.superseded =>
'Quelle wurde upstream geändert — Auswertung veraltet, '
'Neubewertung nötig.',
Freshness.unknown =>
'Aktualität nicht gegen die Quelle abgeglichen.',
};
/// Stale-Zustände bekommen ein sichtbares Badge; `current` nicht.
bool get needsBadge => this != Freshness.current;
}
/// A citable source never an LLM, always a public document or
/// dataset reference. UI surfaces these next to every figure.
class Source {
@ -107,9 +239,12 @@ class Norm {
required this.title,
required this.jurabk,
required this.paragraph,
required this.jurisdiction,
required this.standDate,
required this.sourceUrl,
required this.sourceSha256,
this.freshness = Freshness.current,
this.supersededNote,
this.paragraphs = const [],
});
@ -117,10 +252,23 @@ class Norm {
final String title;
final String jurabk;
final String paragraph;
/// Geltungsbereich Berlin / Deutschland / EU / International.
/// Surfaced as a scope badge wherever the norm appears.
final Jurisdiction jurisdiction;
final DateTime standDate;
final String sourceUrl;
final String sourceSha256;
/// Aktualität dieser Fassung gegenüber der Quelle.
final Freshness freshness;
/// Optionaler Klartext-Hinweis, *was* sich geändert hat (z. B.
/// "BEG IV hat §17 zum 1.1.2026 entschärft"). Nur gesetzt, wenn
/// freshness != current.
final String? supersededNote;
/// The actual paragraphs of the norm text, normalised by
/// `text.akoma_normalize` in live mode. In demo mode the
/// fixture quotes the public source verbatim under §5 UrhG
@ -143,6 +291,8 @@ class Evaluation {
required this.auditEventId,
this.notes,
this.componentSummary = const [],
this.reviewedBy,
this.reviewedAt,
});
final Norm norm;
@ -179,4 +329,15 @@ class Evaluation {
/// extras each carries its tier-tag inline. Empty list
/// fall back to the notes field for the list view.
final List<String> componentSummary;
/// Jurist who confirmed this evaluation (the `by` of the hub
/// approval). Null not yet reviewed. Jurist review is OPTIONAL:
/// the figure stands on its own; a review only *confirms* it.
final String? reviewedBy;
/// When the jurist signed off. Null not reviewed.
final DateTime? reviewedAt;
/// True once a trusted jurist has confirmed this evaluation.
bool get isReviewed => reviewedBy != null;
}