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

@ -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;
}