feat(deadline-system): P3 — three-way detail filter on Verfahrensablauf (m/paliad#149)

m's headline UX ask (2026-05-27 14:58, paliadin priority signal):

  "The new timeline filters for optional / mandatory / show only
   selected is what I am most waiting for. I want this to be
   consolidated for all our deadlines so we can simulate all
   proceedings."

Phase 2 P3. Adds a three-way detail-level filter above the result
panel on /tools/verfahrensablauf:

  ( ) Nur Pflicht     — only priority='mandatory' rules
  (•) Gewählt         — mandatory + recommended (default) + every
                         explicit per-rule override the user has set
                         in projects.scenario_flags
  ( ) Alle Optionen   — every rule, with unselected ones rendered
                         dotted-border + muted so the user sees what
                         they're NOT considering

State persists per-user via localStorage["verfahrensablauf:view_mode"].
The filter is pure client-side narrowing on the calc payload — flipping
the toggle re-renders instantly without a fresh backend call.

Per-rule selection (design §2.4a): every optional / recommended card
now carries an [Aufnehmen] / [Entfernen] chip. Clicking writes a
"rule:<uuid>" entry into the project's scenario_flags via the P0 SSoT
PATCH endpoint, recording only deviations from the priority default:

  recommended + entfernen → rule:<uuid> = false  (explicit deselect)
  optional    + aufnehmen → rule:<uuid> = true   (explicit select)
  flipping back to the default deletes the entry

Mandatory rules never expose the chip — they cannot be deselected.

Wire-shape change: CalculatedDeadline gains `ruleId` (the backend already
emits it as `ruleId` in TimelineEntry; only the frontend interface needed
to surface it).

Conditional handling: a conditional rule whose predicate doesn't fire
is treated as unselected in "Gewählt" mode (even when priority=
mandatory) — mandatory means "must be filed IF the predicate fires",
not "always render". The "Alle Optionen" view re-surfaces it so the
lawyer can see what scenario would unlock it.

Cross-surface coherence: hydrating ?project=<id> reads scenario_flags
from the SSoT and pre-fills the existing flag checkboxes (with_ccr /
with_amend / with_cci) so the page reflects the project's persisted
state on first paint. Every flag toggle + every per-rule chip click
PATCHes back. The page also listens for the scenario-flag-changed
CustomEvent fired by peer surfaces (Mode B Fristenrechner result-view)
and re-renders without a fresh fetch.

i18n: 5 new keys (deadlines.detail.{label,mandatory_only,selected,
all_options,optional_unselected_hint,aufnehmen,entfernen}) DE + EN.

CSS: dotted-border + muted treatment on .timeline-item-header--
unselected; .timeline-selection-chip with --add (lime accent) and
--remove (discreet muted) variants.

Tests: 8 new unit tests covering isRuleSelected (4 priority × 2 flag
state matrix) and filterByDetailMode (3 modes × default/override cases).

Verified: bun build clean, bun test 264/264 (8 new), go vet clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.4a
(selection state model), §3.3a (view-mode toggle), §6 (Entry A spec).
t-paliad-331. Re-prioritised by m via paliadin 14:58.
This commit is contained in:
mAi
2026-05-27 15:20:07 +02:00
parent 3a4e99cb92
commit 480332a5f5
8 changed files with 563 additions and 8 deletions

View File

@@ -1023,6 +1023,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.overhaul.group.conditional": "Bedingt",
"deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren",
"deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.",
"deadlines.detail.label": "Anzeige:",
"deadlines.detail.mandatory_only": "Nur Pflicht",
"deadlines.detail.selected": "Gewählt",
"deadlines.detail.all_options": "Alle Optionen",
"deadlines.detail.optional_unselected_hint": "Diese Regel ist optional und gehört nicht zum aktuellen Szenario.",
"deadlines.detail.aufnehmen": "Aufnehmen",
"deadlines.detail.entfernen": "Entfernen",
"deadlines.overhaul.condition.badge": "Nur unter Bedingung",
"deadlines.overhaul.crossparty.badge": "Gegenseitig",
"deadlines.overhaul.crossparty.tooltip": "Diese Frist wird von der Gegenseite eingereicht. Sie erscheint nur zur Information und wird nicht in die Akte übernommen.",
@@ -4211,6 +4218,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.overhaul.group.conditional": "Conditional",
"deadlines.overhaul.spawn.badge": "⇲ new proceeding",
"deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.",
"deadlines.detail.label": "Detail:",
"deadlines.detail.mandatory_only": "Mandatory only",
"deadlines.detail.selected": "Selected",
"deadlines.detail.all_options": "All options",
"deadlines.detail.optional_unselected_hint": "This rule is optional and not part of the current scenario.",
"deadlines.detail.aufnehmen": "Add",
"deadlines.detail.entfernen": "Remove",
"deadlines.overhaul.condition.badge": "Conditional",
"deadlines.overhaul.crossparty.badge": "Other side",
"deadlines.overhaul.crossparty.tooltip": "This deadline is filed by the opposing party. Shown for information only — not added to the Akte.",

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
import { filterByDetailMode, isRuleSelected } from "./verfahrensablauf-detail-mode";
// Helper — minimum-viable CalculatedDeadline for unit testing the filter
// (the renderer's other fields don't matter to the filter).
function mkRule(
ruleId: string,
priority: "mandatory" | "recommended" | "optional",
extras: Partial<CalculatedDeadline> = {},
): CalculatedDeadline {
return {
ruleId,
code: ruleId,
name: ruleId,
nameEN: ruleId,
party: "",
priority,
ruleRef: "",
dueDate: "2026-06-01",
originalDate: "2026-06-01",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
...extras,
};
}
describe("isRuleSelected", () => {
it("mandatory rules are always selected, even with explicit deselect", () => {
const dl = mkRule("a", "mandatory");
expect(isRuleSelected(dl, null)).toBe(true);
expect(isRuleSelected(dl, { "rule:a": false })).toBe(true);
});
it("recommended rules default to selected; explicit false deselects", () => {
const dl = mkRule("a", "recommended");
expect(isRuleSelected(dl, null)).toBe(true);
expect(isRuleSelected(dl, {})).toBe(true);
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
});
it("optional rules default to unselected; explicit true selects", () => {
const dl = mkRule("a", "optional");
expect(isRuleSelected(dl, null)).toBe(false);
expect(isRuleSelected(dl, {})).toBe(false);
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
});
it("conditional rules are treated as unselected in 'Gewählt' (engine left them unprojected)", () => {
const dl = mkRule("a", "mandatory", { isConditional: true });
expect(isRuleSelected(dl, null)).toBe(false);
});
});
describe("filterByDetailMode", () => {
const deadlines = [
mkRule("anchor", "mandatory", { isRootEvent: true }),
mkRule("m1", "mandatory"),
mkRule("r1", "recommended"),
mkRule("o1", "optional"),
mkRule("o2", "optional"),
];
it("mandatory_only returns mandatory + root only", () => {
const out = filterByDetailMode(deadlines, "mandatory_only", null);
const ids = out.map((d) => d.ruleId);
expect(ids).toEqual(["anchor", "m1"]);
});
it("selected (default flags) returns mandatory + recommended + root", () => {
const out = filterByDetailMode(deadlines, "selected", null);
const ids = out.map((d) => d.ruleId);
expect(ids).toEqual(["anchor", "m1", "r1"]);
});
it("selected with explicit per-rule overrides flips both directions", () => {
const flags = { "rule:r1": false, "rule:o1": true };
const out = filterByDetailMode(deadlines, "selected", flags);
const ids = out.map((d) => d.ruleId);
expect(ids).toEqual(["anchor", "m1", "o1"]);
});
it("all_options returns the full list and tags unselected rules", () => {
const out = filterByDetailMode(deadlines, "all_options", null);
expect(out.length).toBe(5);
const unselected = out.filter(
(d) => (d as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected,
);
// Root + mandatory + recommended are selected; the two optionals
// are unselected → 2 tagged rows.
expect(unselected.map((d) => d.ruleId).sort()).toEqual(["o1", "o2"]);
});
});

View File

@@ -0,0 +1,125 @@
// Detail-level filter for /tools/verfahrensablauf (m/paliad#149 Phase 2 P3).
//
// m's framing (2026-05-27 14:40, design §2.4a + §3.3a):
//
// "It is more that I want a grade of detail in our swimlane display.
// I want to show them but also be able to 'focus' by not displaying
// optional things. We need an option 'show only selected' or
// 'mandatory' ... filter events from the timeline based on whether
// they are selected in this scenario."
//
// Three modes:
// - mandatory_only — render only priority='mandatory' rules
// - selected (default) — mandatory + every rule whose effective
// selection (priority-default OR scenario-flag
// override) is true. Honest summary of "the
// lawyer's scenario".
// - all_options — render everything, with unselected optionals
// rendered dotted-border + muted so the user sees
// what they're NOT considering.
//
// Selection model (per design §2.4a):
// - priority='mandatory' → always selected (cannot be deselected)
// - priority='recommended' → default-selected; rule:<uuid>=false in
// scenario_flags deselects
// - priority='optional' → default-unselected; rule:<uuid>=true in
// scenario_flags selects
// - conditional rules → respect their condition_expr first; if
// the predicate doesn't hold, they're
// effectively unselected regardless of
// their priority default
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
export type DetailMode = "mandatory_only" | "selected" | "all_options";
const STORAGE_KEY = "verfahrensablauf:view_mode";
const DEFAULT_MODE: DetailMode = "selected";
export function getDetailMode(): DetailMode {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "mandatory_only" || raw === "selected" || raw === "all_options") {
return raw;
}
} catch {
// localStorage unavailable (private mode, security policy) — fall
// through to default. Render still works; just no persistence.
}
return DEFAULT_MODE;
}
export function setDetailMode(mode: DetailMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// best-effort
}
}
// isRuleSelected: combine priority default with the scenario-flag
// override map. Returns the effective selection state.
//
// priority='mandatory' → always true
// priority='recommended' → default true, flipped by rule:<uuid>=false
// priority='optional' → default false, flipped by rule:<uuid>=true
// other (informational) → treated as optional
export function isRuleSelected(
dl: CalculatedDeadline,
scenarioFlags: Record<string, boolean> | null,
): boolean {
// A conditional rule that the engine left unprojected (no concrete
// date because its predicate doesn't hold) is effectively unselected
// in "selected" view mode — even for priority='mandatory' rules,
// because mandatory means "must be filed IF the predicate fires",
// not "always render". Surfacing a non-applicable conditional row in
// "Gewählt" would be a lie. The "all_options" view re-surfaces it via
// the unfiltered render path so the lawyer can see what scenarios
// would unlock it.
if (dl.isConditional) return false;
if (dl.priority === "mandatory") return true;
const key = dl.ruleId ? `rule:${dl.ruleId}` : null;
const override = key && scenarioFlags ? scenarioFlags[key] : undefined;
if (typeof override === "boolean") return override;
return dl.priority === "recommended";
}
// filterByDetailMode applies the three-way filter to a deadlines list.
// Returns a NEW array with the appropriate subset; the caller passes
// the filtered list to the existing renderColumnsBody / renderTimelineBody.
//
// all_options: returns the input as-is, with an `__detailUnselected`
// flag set on optionals/conditionals that aren't part of the active
// scenario — the renderer reads this flag to add the dotted-border
// muted styling.
export function filterByDetailMode(
deadlines: CalculatedDeadline[],
mode: DetailMode,
scenarioFlags: Record<string, boolean> | null,
): CalculatedDeadline[] {
if (mode === "all_options") {
// No filtering, but tag the unselected rows so the renderer can
// dim them. The original CalculatedDeadline doesn't carry this
// axis — we stamp it via a cast so the renderer can pick it up
// without growing the public type. Read-only at the renderer side.
return deadlines.map((dl) => {
const unselected = !isRuleSelected(dl, scenarioFlags) && !dl.isRootEvent;
return unselected
? ({ ...dl, __detailUnselected: true } as CalculatedDeadline & { __detailUnselected: true })
: dl;
});
}
if (mode === "mandatory_only") {
return deadlines.filter(
(dl) => dl.priority === "mandatory" || dl.isRootEvent,
);
}
// "selected": mandatory always, plus rules whose effective selection
// is true. Root events always render (they're the proceeding anchor).
return deadlines.filter(
(dl) => dl.isRootEvent || isRuleSelected(dl, scenarioFlags),
);
}

View File

@@ -25,6 +25,19 @@ import {
reseedChips,
type EventChoice,
} from "./views/event-card-choices";
import {
filterByDetailMode,
getDetailMode,
isRuleSelected,
setDetailMode,
type DetailMode,
} from "./verfahrensablauf-detail-mode";
import {
fetchScenarioFlags,
onScenarioFlagsChanged,
patchScenarioFlags,
SCENARIO_FLAG_CHANGED_EVENT,
} from "./scenario-flags";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
@@ -48,6 +61,28 @@ import {
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// m/paliad#149 Phase 2 P3 — detail-level view-mode + scenario-flag state.
//
// detailMode: which of the three filter buckets is active. Persisted in
// localStorage under verfahrensablauf:view_mode so it survives reload
// and follows the user across projects (m's "I want a grade of detail
// in our swimlane display" framing — it's a UI preference, not a
// scenario fact).
//
// projectId: when the page is opened with ?project=<id>, scenario_flag
// reads/writes go through PATCH /api/projects/{id}/scenario-flags (the
// P0 SSoT). Kontextfrei (no project) stays on localStorage via the
// existing perCardChoices path; per-rule selection deviations land in
// scenarioFlagsLocal keyed by proceeding_type code.
//
// scenarioFlags: live map shadow. Refreshed by hydrateScenarioFlags()
// on project pick + listens to the scenario-flag-changed CustomEvent
// so toggles from other surfaces (Mode B Fristenrechner result-view)
// re-trigger re-render here without a fresh fetch.
let detailMode: DetailMode = getDetailMode();
let projectIdForFlags: string | null = null;
let scenarioFlags: Record<string, boolean> = {};
// Perspective state. URL-driven so the view is shareable + survives
// reload:
// ?side=claimant|defendant — swaps which column owns the user's
@@ -470,8 +505,15 @@ function renderResults(data: DeadlineResponse) {
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
: "";
// m/paliad#149 Phase 2 P3 — apply the detail-level filter pre-render.
// The calc payload stays the same; we just narrow what the renderer
// sees. Root events always pass through so the proceeding anchor is
// never hidden by the filter.
const filteredDeadlines = filterByDetailMode(data.deadlines, detailMode, scenarioFlags);
const filteredData: DeadlineResponse = { ...data, deadlines: filteredDeadlines };
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, {
? renderColumnsBody(filteredData, {
editable: true,
showNotes,
showDurations,
@@ -489,7 +531,7 @@ function renderResults(data: DeadlineResponse) {
// m/paliad#136 Bug 1)
appealAware: hasAppealTarget(selectedType),
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
: renderTimelineBody(filteredData, { showParty: true, editable: true, showNotes, showDurations });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
@@ -786,6 +828,120 @@ function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
}
// initDetailModeToggle wires the three-way Anzeige toggle (Nur Pflicht /
// Gewählt / Alle Optionen) introduced for m/paliad#149 Phase 2 P3.
// State persists via localStorage (per-user, per-browser); flipping the
// radio re-renders the last response without a fresh calc — the filter
// is pure client-side narrowing on data the page already has.
function initDetailModeToggle() {
const toggle = document.getElementById("verfahrensablauf-detail-toggle");
if (!toggle) return;
toggle.querySelectorAll<HTMLInputElement>("input[name=detail-mode]").forEach((input) => {
input.checked = input.value === detailMode;
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
if (v === "mandatory_only" || v === "selected" || v === "all_options") {
detailMode = v;
setDetailMode(detailMode);
if (lastResponse) renderResults(lastResponse);
}
});
});
}
// initScenarioFlagsForProject loads the project's persisted scenario_flags
// (mig 154 SSoT). Called when the page is opened with ?project=<id> and
// also when the project autofill resolves a project. Listens for the
// scenario-flag-changed CustomEvent so any peer surface that PATCHes the
// same project (Mode B Fristenrechner result-view) keeps this page in
// sync without polling.
async function hydrateScenarioFlags(projectID: string) {
projectIdForFlags = projectID;
const view = await fetchScenarioFlags(projectID);
if (!view) return;
scenarioFlags = view.flags;
// The named scenario flags (with_ccr / with_amend / with_cci) drive
// the existing flag checkboxes — re-syncing them here makes the page
// reflect the project's persisted state on first paint.
const setChecked = (id: string, val: boolean | undefined): void => {
const el = document.getElementById(id) as HTMLInputElement | null;
if (!el) return;
el.checked = val === true;
};
setChecked("ccr-flag", scenarioFlags["with_ccr"]);
setChecked("inf-amend-flag", scenarioFlags["with_amend"]);
setChecked("rev-amend-flag", scenarioFlags["with_amend"]);
setChecked("rev-cci-flag", scenarioFlags["with_cci"]);
syncInfAmendEnabled();
if (lastResponse) renderResults(lastResponse);
}
// Subscribe to peer-surface scenario-flag changes once at module load.
// The listener is idempotent — we re-read the map and re-render. Skipped
// when projectIdForFlags hasn't been set yet (kontextfrei mode).
onScenarioFlagsChanged((detail) => {
if (!projectIdForFlags || detail.projectId !== projectIdForFlags) return;
scenarioFlags = detail.flags;
if (lastResponse) renderResults(lastResponse);
});
// persistNamedScenarioFlag writes a named flag (with_ccr / with_amend /
// with_cci) to the project's scenario_flags via PATCH. No-op in
// kontextfrei mode. The page-level checkbox owns the click; this helper
// just mirrors it into the SSoT so peer surfaces see the change.
function persistNamedScenarioFlag(key: string, value: boolean): void {
if (!projectIdForFlags) return;
void patchScenarioFlags(projectIdForFlags, { [key]: value });
scenarioFlags = { ...scenarioFlags, [key]: value };
}
// onRuleSelectionToggle handles a click on a per-rule [Aufnehmen] or
// [Entfernen] chip (m/paliad#149 Phase 2 P3, design §2.4a). Translates
// the action into a scenario-flag delta:
//
// priority='recommended', aufnehmen=true → delete rule:<uuid> (back to default-selected)
// priority='recommended', aufnehmen=false → write rule:<uuid> = false (explicit deselect)
// priority='optional', aufnehmen=true → write rule:<uuid> = true (explicit select)
// priority='optional', aufnehmen=false → delete rule:<uuid> (back to default-unselected)
//
// In other words: the chip stores only DEVIATIONS from the priority
// default; flipping back to the default-state deletes the entry. Akte
// mode PATCHes via scenario-flags.ts; kontextfrei mode is no-op
// today (per-rule selections in kontextfrei mode are a future P3
// extension via localStorage; the chip still hides itself once flipped
// because the page-level scenarioFlags map updates).
function onRuleSelectionToggle(ruleId: string, priority: string, aufnehmen: boolean): void {
const key = `rule:${ruleId}`;
let deltaValue: boolean | null;
if (priority === "recommended") {
deltaValue = aufnehmen ? null : false;
} else if (priority === "optional") {
deltaValue = aufnehmen ? true : null;
} else {
return; // mandatory / unknown — not toggleable
}
// Update the local shadow first so the re-render below reflects the
// new state regardless of network latency.
const next = { ...scenarioFlags };
if (deltaValue === null) {
delete next[key];
} else {
next[key] = deltaValue;
}
scenarioFlags = next;
// Persist to the project's SSoT when in akte mode. Fire-and-forget;
// a network failure leaves the local shadow ahead of the server, which
// the next hydrate or peer scenario-flag-changed event reconciles.
if (projectIdForFlags) {
void patchScenarioFlags(projectIdForFlags, { [key]: deltaValue });
}
if (lastResponse) renderResults(lastResponse);
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
@@ -908,17 +1064,24 @@ document.addEventListener("DOMContentLoaded", () => {
// disabled checkbox as checked.
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
// m/paliad#149 Phase 2 P3 — mirror into the project's scenario_flags
// SSoT when in akte mode. PATCH is fire-and-forget; failure just
// means the local UI keeps its optimistic state and the next
// hydrate reconciles.
persistNamedScenarioFlag("with_ccr", ccrFlag.checked);
if (infAmend) persistNamedScenarioFlag("with_amend", infAmend.checked);
scheduleCalc(0);
});
const flagStorageKeys: Record<string, string> = {
"inf-amend-flag": SCENARIO_KEYS.infAmend,
"rev-amend-flag": SCENARIO_KEYS.revAmend,
"rev-cci-flag": SCENARIO_KEYS.revCci,
const flagStorageKeys: Record<string, { storageKey: string; flagKey: string }> = {
"inf-amend-flag": { storageKey: SCENARIO_KEYS.infAmend, flagKey: "with_amend" },
"rev-amend-flag": { storageKey: SCENARIO_KEYS.revAmend, flagKey: "with_amend" },
"rev-cci-flag": { storageKey: SCENARIO_KEYS.revCci, flagKey: "with_cci" },
};
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
for (const [id, { storageKey, flagKey }] of Object.entries(flagStorageKeys)) {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => {
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
persistNamedScenarioFlag(flagKey, cb.checked);
scheduleCalc(0);
});
}
@@ -938,6 +1101,21 @@ document.addEventListener("DOMContentLoaded", () => {
}
scheduleCalc(0);
});
// m/paliad#149 Phase 2 P3 — delegated handler for the per-rule
// [Aufnehmen] / [Entfernen] chips. The chip carries data-rule-id +
// data-action; the click flips the scenario-flag entry for that
// rule and re-renders.
timelineContainer.addEventListener("click", (e) => {
const target = e.target as HTMLElement | null;
const btn = target?.closest<HTMLButtonElement>(".timeline-selection-chip");
if (!btn) return;
e.preventDefault();
const ruleId = btn.dataset.ruleId;
const priority = btn.dataset.priority;
const action = btn.dataset.action;
if (!ruleId || !priority || !action) return;
onRuleSelectionToggle(ruleId, priority, action === "aufnehmen");
});
}
// Notes toggle — restores last preference on load + re-renders when
@@ -981,8 +1159,20 @@ document.addEventListener("DOMContentLoaded", () => {
}
initViewToggle();
initDetailModeToggle();
initPerspectiveControls();
// m/paliad#149 Phase 2 P3 — hydrate scenario_flags from the project's
// SSoT (mig 154) when the page is opened with ?project=<id>. Initial
// fetch sets the page-level flag checkboxes; subsequent peer-surface
// changes arrive via the scenario-flag-changed CustomEvent (subscribed
// at module load above). Kontextfrei (no ?project=) skips the fetch —
// localStorage-only behaviour stays as it was.
const projectQuery = new URLSearchParams(window.location.search).get("project");
if (projectQuery && /^[0-9a-fA-F-]{36}$/.test(projectQuery)) {
void hydrateScenarioFlags(projectQuery);
}
// t-paliad-265 — per-event-card choices. Unbound surface; persistence
// is localStorage-only (t-paliad-308) so a shared link doesn't carry
// the recipient's per-card tweaks. The popover module owns the

View File

@@ -28,6 +28,11 @@ export interface AdjustmentReason {
}
export interface CalculatedDeadline {
// ruleId is the sequencing_rule.id UUID, used by the P3 per-rule
// selection deviations (`rule:<uuid>` keys in projects.scenario_flags).
// Empty on synthetic UI markers like the appeal trigger row that the
// engine prepends — those carry no real rule_id.
ruleId?: string;
code: string;
name: string;
nameEN: string;
@@ -613,13 +618,43 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
data-empty="true"></span>`
: "";
return `<div class="timeline-item-header">
// m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional /
// recommended rules (when the detail-mode filter is in "all_options"
// or "selected"). The detail-mode filter tags unselected rules with
// __detailUnselected; the renderer picks that up to render the chip
// in its "Aufnehmen" state. Mandatory rules never get the chip — the
// user can't deselect them.
const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true;
let selectionChip = "";
if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) {
if (detailUnselected) {
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
data-rule-id="${escAttr(dl.ruleId)}"
data-priority="${escAttr(dl.priority)}"
data-action="aufnehmen"
title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
${escHtml(t("deadlines.detail.aufnehmen"))}
</button>`;
} else if (dl.priority === "recommended" || dl.priority === "optional") {
// The rule IS in the active scenario but can be removed. Renders
// as a discreet [Entfernen] chip on optional / recommended cards.
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
data-rule-id="${escAttr(dl.ruleId)}"
data-priority="${escAttr(dl.priority)}"
data-action="entfernen">
${escHtml(t("deadlines.detail.entfernen"))}
</button>`;
}
}
return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
<span class="timeline-name">
${dlName}
${stateIconsHtml}
${chipHtml}
</span>
${dateStr}
${selectionChip}
${choicesHtml}
</div>
${meta}

View File

@@ -1246,6 +1246,8 @@ export type I18nKey =
| "deadlines.de.inf.olg"
| "deadlines.de.null.bgh"
| "deadlines.de.null.bpatg"
| "deadlines.detail.all_options"
| "deadlines.detail.aufnehmen"
| "deadlines.detail.back"
| "deadlines.detail.cancel"
| "deadlines.detail.complete"
@@ -1259,12 +1261,17 @@ export type I18nKey =
| "deadlines.detail.delete.confirm.title"
| "deadlines.detail.due"
| "deadlines.detail.edit"
| "deadlines.detail.entfernen"
| "deadlines.detail.label"
| "deadlines.detail.loading"
| "deadlines.detail.mandatory_only"
| "deadlines.detail.notes"
| "deadlines.detail.notfound"
| "deadlines.detail.optional_unselected_hint"
| "deadlines.detail.reopen"
| "deadlines.detail.rule"
| "deadlines.detail.save"
| "deadlines.detail.selected"
| "deadlines.detail.source"
| "deadlines.detail.title"
| "deadlines.dpma"

View File

@@ -3704,6 +3704,69 @@ input[type="range"]::-moz-range-thumb {
text-align: right;
}
/* m/paliad#149 Phase 2 P3 — detail-mode unselected card styling +
per-rule Aufnehmen / Entfernen chip.
Unselected cards (priority='optional' or 'recommended' that the user
has chosen NOT to include in the active scenario, surfaced only in
"Alle Optionen" view-mode) render with a dotted border + muted fill,
mirroring the conditional-row treatment. The card stays clickable so
the user can still inspect notes / dates, but it visually reads as
"not part of this scenario right now".
The chip itself is intentionally small and unobtrusive — Aufnehmen
is a lime-tinted call-to-action; Entfernen is a discreet muted chip. */
.timeline-item-header--unselected,
.timeline-item--detail-unselected .timeline-content,
.fr-col-item--detail-unselected {
opacity: 0.7;
}
.timeline-item--detail-unselected .timeline-content,
.fr-col-item--detail-unselected {
border: 1px dashed var(--color-border, #d4d4d4);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--color-bg-soft, #fafafa);
}
.timeline-selection-chip {
appearance: none;
border: 1px solid var(--color-border, #d4d4d4);
background: var(--color-bg, #fff);
color: var(--color-text);
font-size: 0.72rem;
line-height: 1.2;
padding: 0.1rem 0.5rem;
border-radius: 999px;
cursor: pointer;
margin-left: 0.4rem;
white-space: nowrap;
}
.timeline-selection-chip:hover {
background: var(--color-bg-soft, #f4f4f4);
}
.timeline-selection-chip--add {
/* Aufnehmen — pulls the rule into the active scenario. Uses the
Paliad accent (lime) so the call-to-action is visually distinct
from the muted card around it. */
border-color: var(--color-accent, #c6f41c);
background: var(--color-accent-soft-bg, #f0fcd1);
color: var(--color-accent-soft-fg, #4d6b00);
font-weight: 600;
}
.timeline-selection-chip--add::before {
content: "+ ";
}
.timeline-selection-chip--remove {
/* Entfernen — discreet chip on selected optional rows. The user can
restore the priority default by clicking; mandatory rules never
expose this chip. */
color: var(--color-text-muted);
}
.timeline-selection-chip--remove::before {
content: "× ";
}
.event-card-choices-popover {
background: var(--color-bg, #fff);

View File

@@ -327,6 +327,31 @@ export function renderVerfahrensablauf(): string {
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
{/* m/paliad#149 Phase 2 P3 — three-way detail filter.
Controls how much of the procedural shape renders:
just mandatory; mandatory + selected (default); or
every option including unselected ones rendered
muted. State persists in localStorage under
verfahrensablauf:view_mode. The toggle drives a
client-side filter pre-render; the calc payload
stays the same, so flipping is instant. */}
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
role="radiogroup" aria-label="Detail">
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="mandatory_only" />
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="selected" checked />
<span data-i18n="deadlines.detail.selected">Gewählt</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="all_options" />
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
</label>
</div>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">