Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
13fc8fb2f2 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.
2026-05-20 14:51:55 +02:00
6 changed files with 215 additions and 15 deletions

View File

@@ -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 = "";

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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
}

View File

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