diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index d8e76ab..7b4b2cd 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -13,6 +13,7 @@ import { initSidebar } from "./sidebar"; import { type DeadlineResponse, calculateDeadlines, + escHtml, formatDate, populateCourtPicker, renderColumnsBody, @@ -157,13 +158,19 @@ async function doCalc() { // the first event in the proceeding — e.g. Klageerhebung for // upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the // active proceeding name if no root rule fires (shouldn't happen for -// healthy data, but safer than a blank). +// healthy data, but safer than a blank). Fallback respects language — +// proceedingNameEN is consulted on EN before the DE proceedingName +// (m/paliad#58: prior fallback rendered DE on EN for sub-track +// proceedings like upc.ccr.cfi which had no rules → no root). function triggerEventLabelFor(data: DeadlineResponse): string { const root = data.deadlines.find((d) => d.isRootEvent); if (root) { return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN); } - return data.proceedingName || ""; + if (getLang() === "en") { + return data.proceedingNameEN || data.proceedingName || ""; + } + return data.proceedingName || data.proceedingNameEN || ""; } function syncTriggerEventLabel() { @@ -193,11 +200,23 @@ function renderResults(data: DeadlineResponse) { ${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)} `; + // Sub-track contextual note (m/paliad#58). Surfaces above the + // timeline body when the server routed the user-picked proceeding + // through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr). + // Plain-text banner — server-side copy is plain text per the + // SubTrackRouting contract. + const noteText = getLang() === "en" + ? (data.contextualNoteEN || data.contextualNote || "") + : (data.contextualNote || data.contextualNoteEN || ""); + const noteHtml = noteText + ? `
${escHtml(noteText)}
` + : ""; + const bodyHtml = procedureView === "columns" ? renderColumnsBody(data, { editable: true, showNotes }) : renderTimelineBody(data, { showParty: true, editable: true, showNotes }); - container.innerHTML = headerHtml + bodyHtml; + container.innerHTML = headerHtml + noteHtml + bodyHtml; if (printBtn) printBtn.style.display = "block"; if (toggle) toggle.style.display = ""; diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index d69a388..6c5a4e8 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -95,8 +95,21 @@ export function priorityRendering( export interface DeadlineResponse { proceedingType: string; proceedingName: string; + // proceedingNameEN: English label of the picked proceeding. Empty + // when not populated server-side; frontend falls back to + // proceedingName. Used for the "Trigger event" fallback when the + // timeline has no root rule. (m/paliad#58) + proceedingNameEN?: string; triggerDate: string; deadlines: CalculatedDeadline[]; + // contextualNote / contextualNoteEN render as a banner above the + // timeline. Populated when the picked proceeding is a sub-track of + // another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with + // with_ccr) — the server routes to the parent's rules but keeps the + // picked proceeding's identity in the response, and the note + // explains the framing. (m/paliad#58) + contextualNote?: string; + contextualNoteEN?: string; } export interface CourtRow { diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 699c95f..09e1be3 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -3304,6 +3304,23 @@ input[type="range"]::-moz-range-thumb { font-size: 1rem; } +/* Sub-track contextual note banner (m/paliad#58). Renders above the + timeline body when the picked proceeding is a sub-track of another + proceeding (e.g. UPC CCR rendered standalone). Plain-text content; + white-space: pre-line preserves paragraph breaks if server copy + ever uses them. */ +.timeline-context-note { + margin: 0 0 1rem; + padding: 0.7rem 0.9rem; + background: rgba(198, 244, 28, 0.10); + border-left: 3px solid var(--brand-lime, #c6f41c); + border-radius: 4px; + color: var(--color-text, #222); + font-size: 0.9rem; + line-height: 1.4; + white-space: pre-line; +} + .timeline { position: relative; } diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 6982d87..b144959 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -94,10 +94,27 @@ type UIDeadline struct { // UIResponse matches the frontend's DeadlineResponse TypeScript interface. type UIResponse struct { - ProceedingType string `json:"proceedingType"` - ProceedingName string `json:"proceedingName"` - TriggerDate string `json:"triggerDate"` - Deadlines []UIDeadline `json:"deadlines"` + ProceedingType string `json:"proceedingType"` + ProceedingName string `json:"proceedingName"` + // ProceedingNameEN carries the English label of the proceeding so + // the frontend can switch on lang. Empty when the proceeding has no + // English label populated; the frontend falls back to ProceedingName. + // Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf + // "Trigger event" label fell back to the DE proceedingName whenever + // the timeline had no root rule (e.g. for sub-track proceedings like + // upc.ccr.cfi that have no native rules). + ProceedingNameEN string `json:"proceedingNameEN,omitempty"` + TriggerDate string `json:"triggerDate"` + Deadlines []UIDeadline `json:"deadlines"` + // ContextualNote / ContextualNoteEN surface a banner above the + // timeline. Populated by sub-track routing (m/paliad#58): when the + // user picks a proceeding that is normally a sub-track of another + // proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with + // with_ccr), the renderer routes to the parent's rules but keeps + // the user-picked code/name as the response identity and surfaces a + // note explaining the framing. + ContextualNote string `json:"contextualNote,omitempty"` + ContextualNoteEN string `json:"contextualNoteEN,omitempty"` } // ErrUnknownProceedingType is returned when the UI sends an unrecognised code. @@ -237,6 +254,42 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) } + // Sub-track routing (m/paliad#58). When the user picks a proceeding + // that has no native rules and is normally a sub-track of another + // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route + // rule lookup to the parent and merge the default flags into the + // user's flag set. The response identity (Code/Name/NameEN) stays + // on the user-picked proceeding so the page header still reads + // "Counterclaim for Revocation", but the timeline body is the + // parent's full flow with the sub-track flag enabled. A note + // surfaces the framing. + var pickedProceeding = pt + var subTrackNote SubTrackRouting + var hasSubTrackNote bool + if route, ok := LookupSubTrackRouting(proceedingCode); ok { + subTrackNote = route + hasSubTrackNote = true + // Re-resolve to the parent proceeding for rule lookup. + err = s.rules.db.GetContext(ctx, &pt, + `SELECT id, code, name, name_en, jurisdiction + FROM paliad.proceeding_types + WHERE code = $1 AND is_active = true`, route.ParentCode) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType) + } + if err != nil { + return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err) + } + // Merge default flags into the user's flag set so the gated + // rules render. User-supplied flags win on conflict (they're + // already in flagSet); default flags only add what's missing. + for _, f := range route.DefaultFlags { + if _, exists := flagSet[f]; !exists { + flagSet[f] = struct{}{} + } + } + } + // Resolve (country, regime) for non-working-day adjustment. Court wins // when supplied; otherwise default by proceeding regime. UPC proceedings // default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA / @@ -544,12 +597,18 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t deadlines = append(deadlines, d) } - return &UIResponse{ - ProceedingType: pt.Code, - ProceedingName: pt.Name, - TriggerDate: triggerDateStr, - Deadlines: deadlines, - }, nil + resp := &UIResponse{ + ProceedingType: pickedProceeding.Code, + ProceedingName: pickedProceeding.Name, + ProceedingNameEN: pickedProceeding.NameEN, + TriggerDate: triggerDateStr, + Deadlines: deadlines, + } + if hasSubTrackNote { + resp.ContextualNote = subTrackNote.NoteDE + resp.ContextualNoteEN = subTrackNote.NoteEN + } + return resp, nil } // ErrUnknownRule is returned when CalculateRule can't resolve the diff --git a/internal/services/proceeding_mapping.go b/internal/services/proceeding_mapping.go index 2cdc8cf..1174d5c 100644 --- a/internal/services/proceeding_mapping.go +++ b/internal/services/proceeding_mapping.go @@ -132,8 +132,60 @@ func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristen // "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin // weiter." in the UI. func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) { - if code == CodeUPCCounterclaim { - return CodeUPCInfringement, []string{"with_ccr"}, true + if route, ok := SubTrackRoutings[code]; ok { + return route.ParentCode, route.DefaultFlags, true } return code, nil, false } + +// SubTrackRouting describes a proceeding type that has no native rules +// of its own and is normally rendered inside a parent proceeding's flow +// with one or more condition flags enabled. The Procedure Roadmap +// (verfahrensablauf) routes calc requests for these codes to the parent +// proceeding + default flags, but preserves the user-picked code/name +// in the response identity and surfaces a contextual note explaining +// the framing — see m/paliad#58 and the design doc cited above. +// +// Adding a new sub-track is a data-only change here: extend +// SubTrackRoutings with the (code, parent, flags, note) tuple and the +// renderer picks it up automatically. The note copy lives in this file +// because it's semantic to the routing, not UI chrome. +type SubTrackRouting struct { + // Code is the user-picked proceeding code (e.g. "upc.ccr.cfi"). + Code string + // ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi"). + ParentCode string + // DefaultFlags are merged into the user's flag set so the + // gated rules render. Order is preserved. + DefaultFlags []string + // NoteDE / NoteEN are the contextual banner above the timeline, + // explaining that the proceeding type is normally a sub-track. + // Plain text — the frontend renders them as a banner. + NoteDE string + NoteEN string +} + +// SubTrackRoutings — single-source-of-truth registry. Today: just CCR. +// The pattern generalises to other "sub-track" proceeding types (e.g. +// R.30 application to amend the patent as a standalone roadmap, R.46 +// preliminary objection) once they have a proceeding-type code of their +// own. New entries here are picked up by the spawn-as-standalone +// renderer in FristenrechnerService.Calculate without further wiring. +var SubTrackRoutings = map[string]SubTrackRouting{ + CodeUPCCounterclaim: { + Code: CodeUPCCounterclaim, + ParentCode: CodeUPCInfringement, + DefaultFlags: []string{"with_ccr"}, + NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.", + NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.", + }, +} + +// LookupSubTrackRouting returns the sub-track routing for a proceeding +// code, or (zero, false) if the code is not a sub-track. Used by the +// fristenrechner Calculate path to spawn the parent flow with the sub- +// track's default flags. +func LookupSubTrackRouting(code string) (SubTrackRouting, bool) { + r, ok := SubTrackRoutings[code] + return r, ok +} diff --git a/internal/services/proceeding_mapping_test.go b/internal/services/proceeding_mapping_test.go index be50cad..b2b7eba 100644 --- a/internal/services/proceeding_mapping_test.go +++ b/internal/services/proceeding_mapping_test.go @@ -81,3 +81,43 @@ func TestResolveCounterclaimRouting(t *testing.T) { } }) } + +// TestSubTrackRoutings asserts the registry shape m/paliad#58 depends +// on: every entry's Code matches its map key, has a non-empty +// ParentCode + DefaultFlags + bilingual notes. Drift here silently +// breaks the spawn-as-standalone renderer (a CCR pick would 404 or +// render an empty timeline), so we pin the contract. +func TestSubTrackRoutings(t *testing.T) { + if len(SubTrackRoutings) == 0 { + t.Fatal("SubTrackRoutings is empty — at minimum upc.ccr.cfi must be registered") + } + for key, route := range SubTrackRoutings { + if route.Code != key { + t.Errorf("SubTrackRoutings[%q].Code = %q, want %q (key/value mismatch)", key, route.Code, key) + } + if route.ParentCode == "" { + t.Errorf("SubTrackRoutings[%q] has empty ParentCode", key) + } + if len(route.DefaultFlags) == 0 { + t.Errorf("SubTrackRoutings[%q] has no DefaultFlags — sub-track routing without flags is a no-op", key) + } + if route.NoteDE == "" || route.NoteEN == "" { + t.Errorf("SubTrackRoutings[%q] missing bilingual note: DE=%q EN=%q", key, route.NoteDE, route.NoteEN) + } + } + // CCR is the canonical entry — assert its exact shape so a future + // rename doesn't silently change semantics. + ccr, ok := LookupSubTrackRouting(CodeUPCCounterclaim) + if !ok { + t.Fatal("LookupSubTrackRouting(upc.ccr.cfi) returned ok=false; entry must be registered") + } + if ccr.ParentCode != CodeUPCInfringement { + t.Errorf("CCR.ParentCode = %q, want %q", ccr.ParentCode, CodeUPCInfringement) + } + if !reflect.DeepEqual(ccr.DefaultFlags, []string{"with_ccr"}) { + t.Errorf("CCR.DefaultFlags = %v, want [with_ccr]", ccr.DefaultFlags) + } + if _, miss := LookupSubTrackRouting(CodeUPCInfringement); miss { + t.Error("LookupSubTrackRouting(upc.inf.cfi) returned ok=true; non-sub-track codes must miss") + } +}