fix(verfahrensablauf): m/paliad#58 — UPC CCR roadmap (EN label + spawn-as-standalone) #68
@@ -13,6 +13,7 @@ import { initSidebar } from "./sidebar";
|
|||||||
import {
|
import {
|
||||||
type DeadlineResponse,
|
type DeadlineResponse,
|
||||||
calculateDeadlines,
|
calculateDeadlines,
|
||||||
|
escHtml,
|
||||||
formatDate,
|
formatDate,
|
||||||
populateCourtPicker,
|
populateCourtPicker,
|
||||||
renderColumnsBody,
|
renderColumnsBody,
|
||||||
@@ -157,13 +158,19 @@ async function doCalc() {
|
|||||||
// the first event in the proceeding — e.g. Klageerhebung for
|
// the first event in the proceeding — e.g. Klageerhebung for
|
||||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||||
// active proceeding name if no root rule fires (shouldn't happen for
|
// 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 {
|
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||||
if (root) {
|
if (root) {
|
||||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
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() {
|
function syncTriggerEventLabel() {
|
||||||
@@ -193,11 +200,23 @@ function renderResults(data: DeadlineResponse) {
|
|||||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||||
</div>`;
|
</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"
|
const bodyHtml = procedureView === "columns"
|
||||||
? renderColumnsBody(data, { editable: true, showNotes })
|
? renderColumnsBody(data, { editable: true, showNotes })
|
||||||
: renderTimelineBody(data, { showParty: true, 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 (printBtn) printBtn.style.display = "block";
|
||||||
if (toggle) toggle.style.display = "";
|
if (toggle) toggle.style.display = "";
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,21 @@ export function priorityRendering(
|
|||||||
export interface DeadlineResponse {
|
export interface DeadlineResponse {
|
||||||
proceedingType: string;
|
proceedingType: string;
|
||||||
proceedingName: 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;
|
triggerDate: string;
|
||||||
deadlines: CalculatedDeadline[];
|
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 {
|
export interface CourtRow {
|
||||||
|
|||||||
@@ -3289,6 +3289,23 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
font-size: 1rem;
|
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 {
|
.timeline {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,25 @@ type UIDeadline struct {
|
|||||||
type UIResponse struct {
|
type UIResponse struct {
|
||||||
ProceedingType string `json:"proceedingType"`
|
ProceedingType string `json:"proceedingType"`
|
||||||
ProceedingName string `json:"proceedingName"`
|
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"`
|
TriggerDate string `json:"triggerDate"`
|
||||||
Deadlines []UIDeadline `json:"deadlines"`
|
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.
|
// 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)
|
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
|
// Resolve (country, regime) for non-working-day adjustment. Court wins
|
||||||
// when supplied; otherwise default by proceeding regime. UPC proceedings
|
// when supplied; otherwise default by proceeding regime. UPC proceedings
|
||||||
// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
|
// 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)
|
deadlines = append(deadlines, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &UIResponse{
|
resp := &UIResponse{
|
||||||
ProceedingType: pt.Code,
|
ProceedingType: pickedProceeding.Code,
|
||||||
ProceedingName: pt.Name,
|
ProceedingName: pickedProceeding.Name,
|
||||||
|
ProceedingNameEN: pickedProceeding.NameEN,
|
||||||
TriggerDate: triggerDateStr,
|
TriggerDate: triggerDateStr,
|
||||||
Deadlines: deadlines,
|
Deadlines: deadlines,
|
||||||
}, nil
|
}
|
||||||
|
if hasSubTrackNote {
|
||||||
|
resp.ContextualNote = subTrackNote.NoteDE
|
||||||
|
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrUnknownRule is returned when CalculateRule can't resolve the
|
// 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
|
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||||
// weiter." in the UI.
|
// weiter." in the UI.
|
||||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||||
if code == CodeUPCCounterclaim {
|
if route, ok := SubTrackRoutings[code]; ok {
|
||||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
return route.ParentCode, route.DefaultFlags, true
|
||||||
}
|
}
|
||||||
return code, nil, false
|
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