diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index d97edb8..21a082d 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -325,6 +325,10 @@ const translations: Record> = { "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> = { "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", diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 539e26f..ca76b6b 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -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(); diff --git a/frontend/src/client/views/event-card-choices.ts b/frontend/src/client/views/event-card-choices.ts index e17a659..1f7174b 100644 --- a/frontend/src/client/views/event-card-choices.ts +++ b/frontend/src/client/views/event-card-choices.ts @@ -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(".event-card-choices-caret"); - if (target) { + const targetEl = e.target as HTMLElement | null; + const caret = targetEl?.closest(".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(".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 `; } +// 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 { + 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(); diff --git a/frontend/src/client/views/verfahrensablauf-core.test.ts b/frontend/src/client/views/verfahrensablauf-core.test.ts index 78d0956..bfe58da 100644 --- a/frontend/src/client/views/verfahrensablauf-core.test.ts +++ b/frontend/src/client/views/verfahrensablauf-core.test.ts @@ -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 diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index d24b327..e1e48d4 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -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 = { @@ -305,6 +322,22 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string title="${escAttr(t("choices.caret.title"))}">▾` : ""; + // 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 !== "" + ? `` + : ""; + const dlName = getLang() === "en" ? dl.nameEN : dl.name; const adjustedNote = dl.wasAdjusted @@ -359,6 +392,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string ${dateStr} ${choicesHtml} + ${unhideHtml} ${meta} ${adjustedNote} @@ -449,8 +483,12 @@ export function wireDateEditClicks( export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string { let html = '
'; 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 += ` -
+
@@ -629,7 +667,8 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts const mirrorTag = showMirrorTag && dl.party === "both" ? `
↔ ${escHtml(t("deadlines.party.both.label"))}
` : ""; - return `
+ const hiddenCls = dl.isHidden ? " fr-col-item--hidden" : ""; + return `
${deadlineCardHtml(dl, cardOpts)} ${mirrorTag}
`; @@ -680,6 +719,7 @@ export async function calculateDeadlines(params: CalcParams): Promise 0 ? params.perCardChoices : undefined, + includeHidden: params.includeHidden ? true : undefined, }), }); if (!resp.ok) { diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 959fea1..930fb38 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -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" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 8f1aab6..eeeb450 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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); diff --git a/frontend/src/verfahrensablauf.tsx b/frontend/src/verfahrensablauf.tsx index 372d237..04fad48 100644 --- a/frontend/src/verfahrensablauf.tsx +++ b/frontend/src/verfahrensablauf.tsx @@ -224,6 +224,19 @@ export function renderVerfahrensablauf(): string {
+ {/* 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. */} +
{/* Visual divider — keeps the perspective block (most- diff --git a/internal/handlers/fristenrechner.go b/internal/handlers/fristenrechner.go index c22a761..2687cf6 100644 --- a/internal/handlers/fristenrechner.go +++ b/internal/handlers/fristenrechner.go @@ -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) { diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index de30811..032629f 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -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.