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")
+ }
+}