fix(verfahrensablauf): m/paliad#58 — UPC CCR roadmap (EN label + spawn-as-standalone)
m's 2026-05-20 14:08 reports on /tools/verfahrensablauf:
1. "There seems to be a lacking english term here" — picking
UPC CCR shows "Trigger event: Widerklage auf Nichtigkeit" on EN.
2. "Nothing shows in the roadmap" — the timeline is empty because
upc.ccr.cfi has no native rules (it's an illustrative peer that
normally runs as a sub-track of upc.inf.cfi with with_ccr).
Root cause for (1): UIResponse.proceedingName was DE-only. When a
proceeding had no root rule the frontend fell back to that field, so
EN users saw the DE label. The DB already has bilingual names; this
was pure plumbing.
Root cause for (2): the upc.ccr.cfi proceeding-type row exists for
the picker (mig 096) but ResolveCounterclaimRouting — the helper
that maps it to upc.inf.cfi with the with_ccr flag — was defined
but never called. Calculate queried rules directly off upc.ccr.cfi
and got an empty list.
Fix:
* Add ProceedingNameEN, ContextualNote, ContextualNoteEN to
UIResponse. Frontend triggerEventLabelFor now consults the EN
name on EN, falling back to DE only if the EN field is empty.
* New SubTrackRouting registry in proceeding_mapping.go and a
LookupSubTrackRouting lookup — single source of truth for the
"this proceeding has no native rules, route to a parent with
flags + show a contextual note" pattern. Today's only entry is
upc.ccr.cfi → upc.inf.cfi + with_ccr; the pattern generalises
to other sub-tracks via data-only additions.
* Calculate consults the registry at the top: when a hit, the
proceeding type is re-resolved to the parent for rule lookup, the
default flags are merged into the user's flag set (user flags win
on conflict), and the response identity (Code/Name/NameEN) stays
on the user-picked proceeding so the page header still reads
"Counterclaim for Revocation". The bilingual note surfaces in
ContextualNote{,EN}.
* Frontend renderResults paints a lime-accent banner above the
timeline body when the response carries a note
(.timeline-context-note). escHtml already exported from
views/verfahrensablauf-core — imported here for the banner.
No DB migration: SELECTs against paliad.proceeding_types,
paliad.deadline_rules, and paliad.trigger_events confirm every
active row already has a non-empty name_en / name. The bug was
the API + frontend never reading the EN columns through the
proceedingName fallback path.
Tests: TestSubTrackRoutings pins the registry shape (every entry
has matching key/value, non-empty parent+flags, bilingual notes;
CCR's exact shape is asserted; non-sub-tracks miss). The existing
TestResolveCounterclaimRouting continues to pass because the
helper now consults the registry but the CCR semantics are
unchanged.
This commit is contained in:
@@ -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) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// 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
|
||||
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
||||
: "";
|
||||
|
||||
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 = "";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3289,6 +3289,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user