feat(verfahrensablauf): re-surface hidden optional events — show-hidden toggle + un-hide chip (t-paliad-290)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no
un-skip path — hidden cards just disappeared from the timeline. This
adds the missing return path:

- CalcOptions.IncludeHidden (default false) tells the calculator to
  re-surface skipRules entries as faded rows instead of dropping them.
  When true, the rule renders with UIDeadline.IsHidden=true and the
  descendant-suppression cascade is bypassed so children compute their
  dates off the un-suppressed parent.
- UIResponse.HiddenCount always reflects the projection's hide count
  (gate-passed rules whose submission_code is in skipRules) so the
  "Ausgeblendete (N)" badge stays accurate regardless of toggle state.
- /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next
  to the perspective + appellant selectors. URL-driven (?show_hidden=1)
  so the state is shareable and survives reload. The row hides itself
  on projections with zero hidden cards.
- Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden
  (opacity 0.55 + dotted border, mirroring the existing
  --skipped fade) and carry an inline "Wieder einblenden" chip. Clicking
  the chip removes the skip choice via the page's existing
  attachEventCardChoices remove callback (URL state + recalc included)
  and runs through a new delegated handler in event-card-choices.ts.
- 3 new i18n keys (DE+EN): choices.show_hidden.label,
  choices.show_hidden.count, choices.unhide.chip.

The skip-choice storage shape (paliad.project_event_choices, atlas's
table) is unchanged — un-hide is just a delete of the skip row.

Tests: 3 new bun-test cases pin the chip contract (emits on isHidden=
true with submission_code, suppressed otherwise); go test ./internal/...
+ bun run build clean.
This commit is contained in:
mAi
2026-05-26 09:38:31 +02:00
parent cca5e72c57
commit 80883eaac5
10 changed files with 272 additions and 7 deletions

View File

@@ -325,6 +325,10 @@ const translations: Record<Lang, Record<string, string>> = {
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
"choices.reset": "Auswahl zurücksetzen",
"choices.commit.error": "Konnte Auswahl nicht speichern",
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
"choices.show_hidden.label": "Ausgeblendete anzeigen",
"choices.show_hidden.count": "Ausgeblendete ({n})",
"choices.unhide.chip": "Wieder einblenden",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
"deadlines.mode.event": "Was kommt nach\u2026",
@@ -3422,6 +3426,10 @@ const translations: Record<Lang, Record<string, string>> = {
"choices.include_ccr.chip": "with nullity counterclaim",
"choices.reset": "Reset choice",
"choices.commit.error": "Could not save selection",
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
"choices.show_hidden.label": "Show hidden",
"choices.show_hidden.count": "Hidden ({n})",
"choices.unhide.chip": "Show again",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",

View File

@@ -143,6 +143,25 @@ function writeChoicesToURL(choices: EventChoice[]) {
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
// calculator re-surfaces cards whose submission_code is in the active
// skipRules set; they render faded with a "Wieder einblenden" chip.
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
// the visibility. Default OFF — m's not asking to see hidden by
// default, just to be able to.
function readShowHiddenFromURL(): boolean {
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
}
function writeShowHiddenToURL(on: boolean) {
const url = new URL(window.location.href);
if (on) url.searchParams.set("show_hidden", "1");
else url.searchParams.delete("show_hidden");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
let showHidden = readShowHiddenFromURL();
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
@@ -256,14 +275,33 @@ async function doCalc() {
anchorOverrides: overrides,
courtId,
perCardChoices,
includeHidden: showHidden,
});
if (seq !== calcSeq) return;
if (!data) return;
lastResponse = data;
renderResults(data);
syncHiddenBadge(data.hiddenCount ?? 0);
showStep(3);
}
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
// toggle. Visible regardless of toggle state so the user knows whether
// there's anything to re-surface even when the toggle is OFF. Hides the
// whole row when the projection has zero hidden cards — no clutter on
// a project that's never used the skip feature. (t-paliad-290)
function syncHiddenBadge(count: number) {
const row = document.getElementById("show-hidden-row");
const badge = document.getElementById("show-hidden-count");
if (!row || !badge) return;
if (count <= 0) {
row.style.display = "none";
return;
}
row.style.display = "";
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. Precedence:
//
@@ -696,6 +734,20 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
// to URL + recalc (the backend reshapes the response — we can't just
// re-render lastResponse since the hidden rows aren't in it when the
// toggle was OFF).
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
if (showHiddenCb) {
showHiddenCb.checked = showHidden;
showHiddenCb.addEventListener("change", () => {
showHidden = showHiddenCb.checked;
writeShowHiddenToURL(showHidden);
scheduleCalc(0);
});
}
initViewToggle();
initPerspectiveControls();

View File

@@ -74,10 +74,22 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
if (target) {
const targetEl = e.target as HTMLElement | null;
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
if (caret) {
e.stopPropagation();
openPopover(state, target);
openPopover(state, caret);
return;
}
// t-paliad-290: "Wieder einblenden" chip — direct un-hide path that
// mirrors the popover's reset on the `skip` kind. The chip only
// renders on hidden cards (server-flagged via UIDeadline.IsHidden),
// so we always have a real skip entry to remove.
const unhide = targetEl?.closest<HTMLElement>(".event-card-choices-unhide");
if (unhide) {
e.stopPropagation();
const code = unhide.dataset.submissionCode || "";
if (code) void unhideCard(state, code);
return;
}
// Outside-click closes the popover.
@@ -259,6 +271,23 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
</div>`;
}
// unhideCard removes the `skip` choice on the given submission_code via
// the page-supplied remove() callback, then repaints chips so the card
// loses its fade. The page's remove() also triggers a recalc — the
// re-surfaced card will then drop out of the result list naturally
// (since IncludeHidden is still on but the skip entry is gone). Errors
// surface in the console; the chip stays clickable for a retry.
// (t-paliad-290)
async function unhideCard(state: AttachedState, code: string): Promise<void> {
try {
await state.opts.remove(code, "skip");
state.active.get(code)?.delete("skip");
reseedChips(state.opts.container);
} catch (err) {
console.error("event card un-hide failed", err);
}
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();

View File

@@ -67,6 +67,31 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
});
});
// t-paliad-290 (m/paliad#122): the "Ausgeblendete anzeigen" toggle
// surfaces hidden cards via UIDeadline.IsHidden=true. The renderer
// must (a) emit an inline "Wieder einblenden" chip carrying the
// submission_code (so the delegated handler in event-card-choices.ts
// can resolve which skip to clear) and (b) NOT emit the chip when
// either isHidden is false or the rule has no submission_code (no
// hide target to undo).
describe("deadlineCardHtml — isHidden inline 'Wieder einblenden' chip (t-paliad-290)", () => {
test("isHidden=true with submission_code emits unhide chip with data-submission-code", () => {
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
expect(html).toContain("event-card-choices-unhide");
expect(html).toContain('data-submission-code="upc-rop-12"');
});
test("isHidden=false (default) suppresses unhide chip", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).not.toContain("event-card-choices-unhide");
});
test("isHidden=true on a rule with no submission_code suppresses unhide chip", () => {
const html = deadlineCardHtml(dl({ code: "", isHidden: true }), { showParty: true });
expect(html).not.toContain("event-card-choices-unhide");
});
});
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the

View File

@@ -72,6 +72,11 @@ export interface CalculatedDeadline {
// page-level appellant axis still applies in that case). The bucketer
// reads this in preference to the page-level appellant.
appellantContext?: string;
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
// a previously-hidden card is re-surfaced via the "Ausgeblendete
// anzeigen" toggle. The renderer fades the card and exposes an
// inline "Wieder einblenden" chip that deletes the skip choice.
isHidden?: boolean;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -131,6 +136,13 @@ export interface DeadlineResponse {
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
// would have been hidden in this projection (i.e. their
// submission_code is in skipRules and they passed the condition_expr
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
// when the toggle is OFF — so users know there's something to
// re-surface.
hiddenCount?: number;
}
export interface CourtRow {
@@ -160,6 +172,11 @@ export interface CalcParams {
choice_kind: string;
choice_value: string;
}>;
// includeHidden (t-paliad-290): when true the calculator returns
// previously-skipped rules as faded cards instead of dropping them.
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
// ON.
includeHidden?: boolean;
}
const PARTY_CLASS: Record<string, string> = {
@@ -305,6 +322,22 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
title="${escAttr(t("choices.caret.title"))}">▾</button>`
: "";
// t-paliad-290 — inline "Wieder einblenden" chip on re-surfaced
// hidden cards. Click deletes the skip choice (mirroring the popover
// reset path). The chip only renders when the card is hidden in the
// current projection (IsHidden=true on the wire) so it's always
// pointing at a real skip entry. The chip text is a static i18n
// value (no user input), so we use escAttr-only for attribute safety
// and inline the translated label directly — matches the renderer's
// pattern for the deadline name (also a known-safe string).
const unhideLabel = t("choices.unhide.chip");
const unhideHtml = dl.isHidden && dl.code !== ""
? `<button type="button" class="event-card-choices-unhide"
data-submission-code="${escAttr(dl.code)}"
aria-label="${escAttr(unhideLabel)}"
title="${escAttr(unhideLabel)}">${unhideLabel}</button>`
: "";
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
@@ -359,6 +392,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</span>
${dateStr}
${choicesHtml}
${unhideHtml}
</div>
${meta}
${adjustedNote}
@@ -449,8 +483,12 @@ export function wireDateEditClicks(
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared timeline-item--hidden modifier (same modifier the columns
// view uses; see fr-col-item--hidden below).
const hiddenCls = dl.isHidden ? " timeline-item--hidden" : "";
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}${hiddenCls}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
@@ -629,7 +667,8 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
const hiddenCls = dl.isHidden ? " fr-col-item--hidden" : "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}${hiddenCls}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
@@ -680,6 +719,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
? params.perCardChoices
: undefined,
includeHidden: params.includeHidden ? true : undefined,
}),
});
if (!resp.ok) {

View File

@@ -1021,10 +1021,13 @@ export type I18nKey =
| "choices.include_ccr.title"
| "choices.include_ccr.true"
| "choices.reset"
| "choices.show_hidden.count"
| "choices.show_hidden.label"
| "choices.skip.false"
| "choices.skip.title"
| "choices.skip.true"
| "choices.skipped.chip"
| "choices.unhide.chip"
| "common.cancel"
| "common.close"
| "common.forbidden"

View File

@@ -3531,6 +3531,46 @@ input[type="range"]::-moz-range-thumb {
opacity: 0.55;
}
/* t-paliad-290 (m/paliad#122) — re-surfaced "hidden" cards. The user
* has previously marked these optional events as "Überspringen"; the
* "Ausgeblendete anzeigen" toggle on /tools/verfahrensablauf returns
* them with a faded + dotted-border treatment so they're visually
* distinct from the active timeline. The inline "Wieder einblenden"
* chip cancels the skip on click. */
.timeline-item--hidden .timeline-content,
.fr-col-item--hidden {
opacity: 0.55;
border: 1px dotted var(--color-border, #d4d4d4);
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
.event-card-choices-unhide {
margin-left: 0.4rem;
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 99px;
border: 1px solid var(--color-accent, #c6f41c);
background: var(--color-accent, #c6f41c);
color: var(--color-text);
cursor: pointer;
/* Cancel the wrapper fade so the action remains a clear, high-
* contrast affordance even though the rest of the card is muted. */
opacity: 1;
}
.event-card-choices-unhide:hover,
.event-card-choices-unhide:focus-visible {
background: var(--color-bg, #fff);
}
.show-hidden-count {
font-size: 0.78rem;
color: var(--color-text-muted);
margin-left: 0.4rem;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);

View File

@@ -224,6 +224,19 @@ export function renderVerfahrensablauf(): string {
</label>
</div>
</div>
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
Re-surfaces optional cards the user has previously
marked "Überspringen" via the per-card popover.
The row hides itself when the projection has no
hidden cards (handled in client/verfahrensablauf.ts).
Default OFF; URL state ?show_hidden=1. */}
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
{/* Visual divider — keeps the perspective block (most-

View File

@@ -63,6 +63,12 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
// wins (what-if exploration overrides the saved state).
ProjectID string `json:"projectId,omitempty"`
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
// optional cards. When true the calculator marks skipped rows
// with UIDeadline.IsHidden instead of dropping them; descendants
// stay in the result list. Default false preserves the legacy
// suppression. HiddenCount on the response is independent.
IncludeHidden bool `json:"includeHidden,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -109,6 +115,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -102,6 +102,12 @@ type UIDeadline struct {
// Frontend bucketer prefers this over the page-level appellant when
// non-empty. (t-paliad-265)
AppellantContext string `json:"appellantContext,omitempty"`
// IsHidden marks a card the user has previously hidden via a
// skip choice. Only ever true when CalcOptions.IncludeHidden is
// set — the toggle re-surfaces these rows so the user can either
// keep them faded for context or un-hide them via the inline
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
IsHidden bool `json:"isHidden,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
@@ -137,6 +143,14 @@ type UIResponse struct {
// is the appealable first-instance decision (m/paliad#81).
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
// HiddenCount is the number of rules whose submission_code is in
// CalcOptions.SkipRules AND whose condition_expr gate passes —
// i.e. how many rows the user has hidden in this projection
// regardless of the IncludeHidden toggle state. The frontend uses
// this to render the "Ausgeblendete (N)" badge on the toggle even
// when the toggle is OFF (so users know there's something to
// re-surface). (t-paliad-290 / m/paliad#122)
HiddenCount int `json:"hiddenCount"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -214,6 +228,19 @@ type CalcOptions struct {
PerCardAppellant map[string]string
SkipRules map[string]struct{}
IncludeCCRFor map[string]struct{}
// IncludeHidden re-surfaces rules whose submission_code is in
// SkipRules (t-paliad-290 / m/paliad#122). When true:
// - Skipped rules are NOT dropped from the result; they render
// with UIDeadline.IsHidden=true so the frontend can fade them.
// - Descendant suppression is bypassed (the skipped parent is
// present in the result, so children compute their dates off
// it as if the user had never hidden it).
// Default false preserves the original skip semantic (drop rule +
// suppress descendants). HiddenCount on UIResponse is independent
// of this flag — it always reflects the number of hide-eligible
// rows so the toggle's count badge stays accurate.
IncludeHidden bool
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -381,6 +408,13 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// child rule's parent has already been classified — so descendant
// suppression is a one-pass parent_id lookup.
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
// hiddenCount counts rows whose submission_code is in skipRules
// AND that pass the condition_expr gate — i.e. rows the user has
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
// IncludeHidden is off and the rows aren't in the result list.
// (t-paliad-290 / m/paliad#122)
hiddenCount := 0
// appellantContext maps a rule UUID to the appellant value that
// applies to its descendants. A rule that has its own PerCardAppellant
// pick stamps itself with that value; a rule whose parent has a
@@ -403,10 +437,22 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// this rule (or one of its ancestors) as "don't consider for
// this case". Drop the row entirely AND record the rule ID so
// descendants suppress too.
//
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
// instead of dropping it. Descendants are NOT cascade-suppressed
// in that mode either — the un-suppressed parent computes its
// date normally, so children compute off it as usual. Either
// way we count the hide for the toggle's badge.
var isHidden bool
if r.SubmissionCode != nil {
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
skippedIDs[r.ID] = struct{}{}
continue
hiddenCount++
if !opts.IncludeHidden {
skippedIDs[r.ID] = struct{}{}
continue
}
isHidden = true
}
}
if r.ParentID != nil {
@@ -442,6 +488,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
ConditionExpr: json.RawMessage(r.ConditionExpr),
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
IsHidden: isHidden,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -712,6 +759,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding` (e.g.