The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.
upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.
Fix layers:
- Go handler (internal/handlers/fristenrechner.go): accept
includeOptional + triggerEventAnchors from request body and
forward to services.CalcOptions. Default zero values match the
engine defaults (suppress optionals + no fabricated dates for
trigger_event_id rules), so the wire is unchanged when callers
don't set them.
- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
add the same two fields to CalcParams + forward in the fetch body;
surface rulesAwaitingAnchor on DeadlineResponse mirroring
Timeline.RulesAwaitingAnchor.
- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
apply filterByDetailMode(detailgrad) before renderColumnsBody, with
detailgrad sourced from the proceeding row. "selected" (default)
drops conditional + optional rules; "all_options" passes
includeOptional=true so the engine returns the optional rules the
user can opt into.
- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
pass includeOptional based on detailMode + a small hasOptionalOptIn
helper so per-rule rule:<uuid>=true deviations still surface their
optional rule even in "selected" mode (the engine has no rule:<uuid>
awareness; without the opt-in the user's pick would silently no-op).
Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
fetch body shape - includeOptional=true and triggerEventAnchors={...}
round-trip through the request; empty/default values are omitted so
the wire stays minimal.
bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.
(m/paliad#153)
1235 lines
54 KiB
TypeScript
1235 lines
54 KiB
TypeScript
// Shared core for Fristenrechner-style proceeding-timeline rendering.
|
||
//
|
||
// Both /tools/fristenrechner (deadline determination) and
|
||
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
|
||
// POST /api/tools/fristenrechner and paint the result with the same
|
||
// renderers. The module is pure-functional: no shared mutable state, all
|
||
// language / overrides / editability flow in through args so the two
|
||
// pages can wire their own per-page concerns (Akte save, anchor edits,
|
||
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
|
||
// to verfahrensablauf in later slices) without leaking into each other.
|
||
|
||
import { t, tDyn, getLang } from "../i18n";
|
||
|
||
export interface AdjustmentHoliday {
|
||
Date: string;
|
||
Name: string;
|
||
IsVacation: boolean;
|
||
IsClosure: boolean;
|
||
}
|
||
|
||
export interface AdjustmentReason {
|
||
kind: "weekend" | "public_holiday" | "vacation";
|
||
holidays?: AdjustmentHoliday[];
|
||
vacation_name?: string;
|
||
vacation_start?: string;
|
||
vacation_end?: string;
|
||
original_weekday?: string;
|
||
}
|
||
|
||
export interface CalculatedDeadline {
|
||
// ruleId is the sequencing_rule.id UUID, used by the P3 per-rule
|
||
// selection deviations (`rule:<uuid>` keys in projects.scenario_flags).
|
||
// Empty on synthetic UI markers like the appeal trigger row that the
|
||
// engine prepends — those carry no real rule_id.
|
||
ruleId?: string;
|
||
code: string;
|
||
name: string;
|
||
nameEN: string;
|
||
party: string;
|
||
// Priority is the canonical 4-way enum (Slice 8 made it canonical;
|
||
// Slice 9 dropped the legacy isMandatory / isOptional pair from the
|
||
// wire). priorityRendering(d) below branches on it.
|
||
priority: "mandatory" | "recommended" | "optional" | "informational";
|
||
ruleRef: string;
|
||
legalSource?: string;
|
||
// legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
|
||
// by FormatLegalSourceDisplay on the backend. Renderer prefers this
|
||
// over ruleRef when set; falls back to ruleRef otherwise.
|
||
legalSourceDisplay?: string;
|
||
// legalSourceURL is the youpc.org/laws permalink when the cited body
|
||
// is hosted there (UPCRoP / UPCA / UPCS today). Empty for DE/EPA/EU
|
||
// bodies — the renderer shows display text without a link.
|
||
legalSourceURL?: string;
|
||
notes?: string;
|
||
notesEN?: string;
|
||
dueDate: string;
|
||
originalDate: string;
|
||
wasAdjusted: boolean;
|
||
adjustmentReason?: AdjustmentReason;
|
||
isRootEvent: boolean;
|
||
isCourtSet: boolean;
|
||
isCourtSetIndirect?: boolean;
|
||
isOverridden?: boolean;
|
||
// conditionExpr surfaces the jsonb gate predicate (design §2.4) so
|
||
// the rule-editor + admin views can render the rule's gating shape.
|
||
// Frontend save-modal logic doesn't read this; the rule editor
|
||
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
|
||
conditionExpr?: unknown;
|
||
// choicesOffered (t-paliad-265): declares which per-card choice-kinds
|
||
// this rule offers on the Verfahrensablauf timeline. Object shape:
|
||
// { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
|
||
// null/undefined = no caret affordance.
|
||
choicesOffered?: Record<string, unknown>;
|
||
// appellantContext (t-paliad-265): the per-decision appellant pick
|
||
// that applies to descendants of the closest ancestor decision card
|
||
// with a per-card appellant set. Empty = no per-card override (the
|
||
// page-level appellant axis still applies in that case). The bucketer
|
||
// reads this in preference to the page-level appellant.
|
||
appellantContext?: string;
|
||
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
|
||
// a previously-hidden card is re-surfaced via the "Ausgeblendete
|
||
// anzeigen" toggle. The renderer fades the card and exposes an
|
||
// inline "Wieder einblenden" chip that deletes the skip choice.
|
||
isHidden?: boolean;
|
||
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
|
||
// no concrete date is projected. Set by the calculator when the rule
|
||
// depends on a court-set ancestor without override, when a backward-
|
||
// anchored rule's forward anchor isn't set, or for optional rules
|
||
// whose true triggering event sits outside the rule data (e.g.
|
||
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
|
||
// in the data, but the real trigger is the opposing party's
|
||
// confidentiality motion). The renderer drops the date column entry
|
||
// and shows an "abhängig von <parentRuleName>" chip instead.
|
||
isConditional?: boolean;
|
||
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
|
||
// parent rule's identity so the renderer can label the
|
||
// "abhängig von <parent>" chip on conditional rows. Populated for
|
||
// every rule with a parent (not just conditional ones), so the
|
||
// dependency-footer logic can reuse it. Empty for root rules.
|
||
parentRuleCode?: string;
|
||
parentRuleName?: string;
|
||
parentRuleNameEN?: string;
|
||
// durationValue / durationUnit / timing surface the rule's arithmetic
|
||
// so the timeline card can show "2 Mo. nach" on hover (and inline when
|
||
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
|
||
// event, court-set) carry durationValue=0 and the renderer suppresses
|
||
// the affordance — those don't have an explainable interval.
|
||
// (m/paliad#133, t-paliad-302)
|
||
durationValue?: number;
|
||
durationUnit?: string;
|
||
timing?: string;
|
||
// appealRole carries the rule's appeal-filer identity when the
|
||
// server computed the timeline under an appeal_target filter:
|
||
// "appellant" (Berufungskläger files this rule), "appellee"
|
||
// (Berufungsbeklagter files this rule), or empty for court events
|
||
// and non-appeal timelines. The column bucketer reads this in
|
||
// preference to primary_party='both' so a user-perspective `?side=`
|
||
// pick can split appeal filings into the user's column vs the
|
||
// opponent's, instead of routing every "both" rule into the
|
||
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
|
||
appealRole?: "appellant" | "appellee" | "";
|
||
// isTriggerEvent marks the synthetic row the engine prepends to the
|
||
// timeline when computing an appeal: a court-set decision dated to
|
||
// the trigger date with the per-appeal-target label
|
||
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
|
||
// carries no real rule_id — it's a UI marker so the timeline reads
|
||
// decision → appeal filings → next decision. (t-paliad-307 /
|
||
// m/paliad#136 Bug 2)
|
||
isTriggerEvent?: boolean;
|
||
}
|
||
|
||
// stripLeadingDurationFromNotes drops the leading
|
||
// "Frist N <unit> <preposition> <subject>." (DE) /
|
||
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
|
||
// deadline_notes so it doesn't duplicate the new duration affordance
|
||
// added in m/paliad#133 (t-paliad-307 Bug 4).
|
||
//
|
||
// The duration affordance now renders the same prose as a badge on
|
||
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
|
||
// notes string that opens with the same prose reads as a verbatim
|
||
// duplicate. Only the leading-prefix shape is stripped — anything
|
||
// after the first sentence is preserved (the editorial commentary
|
||
// the lawyers actually want to read).
|
||
//
|
||
// Conservative: composite-duration prefaces with "ODER" /
|
||
// "whichever is the longer" don't match and stay untouched — those
|
||
// are the follow-up editorial cleanup (option b in the issue brief).
|
||
//
|
||
// Examples:
|
||
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
|
||
// → "Antrag …"
|
||
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
|
||
// → ""
|
||
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
|
||
// → "Spätestens …"
|
||
// "1-month period from service of the main decision"
|
||
// → ""
|
||
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
|
||
// → "Request for …"
|
||
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
|
||
// → "Latest …"
|
||
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
|
||
// → unchanged (composite — option b follow-up)
|
||
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
|
||
if (!notes) return notes;
|
||
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
|
||
// (period followed by whitespace) OR end of input. Embedded dots
|
||
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
|
||
// are skipped because the char right after them isn't whitespace.
|
||
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
|
||
// any character including newlines, non-greedy.
|
||
const re = lang === "en"
|
||
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
|
||
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
|
||
return notes.replace(re, "");
|
||
}
|
||
|
||
// formatDurationLabel renders the per-rule duration label for the
|
||
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
|
||
// "1 Monat vor Mündlicher Verhandlung", …
|
||
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
|
||
// m/paliad#136 Bug 3).
|
||
//
|
||
// Returns empty string for rules without a usable duration so the
|
||
// caller can skip the tooltip / inline span entirely. Pluralisation
|
||
// key naming mirrors the Fristenrechner event-mode renderer
|
||
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
|
||
// translations already exist for /tools/fristenrechner's
|
||
// "Was kommt nach…" mode and are reused here as the single source
|
||
// of truth.
|
||
//
|
||
// `parentLabel` is the rule's anchor name (parent rule's name when
|
||
// the rule has a parent_id; otherwise the proceeding's
|
||
// triggerEventLabel from the wire). Empty falls back to bare
|
||
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
|
||
// remains the default for fixtures / tests that omit a parent.
|
||
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
|
||
const value = dl.durationValue ?? 0;
|
||
const unit = dl.durationUnit || "";
|
||
if (value <= 0 || !unit) return "";
|
||
const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
|
||
const unitStr = tDyn(unitKey);
|
||
const timing = dl.timing || "";
|
||
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
|
||
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||
if (!timingStr || !parentLabel) return head;
|
||
return `${head} ${parentLabel}`;
|
||
}
|
||
|
||
// priorityRendering returns the per-priority UX hints the save-modal
|
||
// uses. Maps the unified Priority enum to:
|
||
// - preChecked: whether the save-modal pre-checks the row
|
||
// - hideSave: whether the row renders without a save button at all
|
||
// (informational = notice card, no save action)
|
||
//
|
||
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy
|
||
// (isMandatory, isOptional) fallback that pre-Slice-8 backends
|
||
// emitted. The backend now always populates `priority`; an unknown
|
||
// value falls back to "render as mandatory" (safe default — never
|
||
// silently drop a rule).
|
||
export function priorityRendering(
|
||
d: CalculatedDeadline,
|
||
): { preChecked: boolean; hideSave: boolean } {
|
||
switch (d.priority) {
|
||
case "mandatory":
|
||
case "recommended":
|
||
return { preChecked: true, hideSave: false };
|
||
case "optional":
|
||
return { preChecked: false, hideSave: false };
|
||
case "informational":
|
||
return { preChecked: false, hideSave: true };
|
||
}
|
||
// Unknown priority value: pre-Slice-8 backend or a forward-compat
|
||
// future value. Safe default: render as mandatory so the rule is
|
||
// surfaced + saved. Never silently drop.
|
||
return { preChecked: true, hideSave: false };
|
||
}
|
||
|
||
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;
|
||
// triggerEventLabel / triggerEventLabelEN: optional caption for the
|
||
// "Auslösendes Ereignis" / "Triggering event" field on
|
||
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
|
||
// when set (mig 121). The page prefers this over the proceedingName
|
||
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
|
||
// uses this so the field reads "Anfechtbare Entscheidung" /
|
||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||
// (m/paliad#81)
|
||
triggerEventLabel?: string;
|
||
triggerEventLabelEN?: string;
|
||
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
|
||
// would have been hidden in this projection (i.e. their
|
||
// submission_code is in skipRules and they passed the condition_expr
|
||
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
|
||
// when the toggle is OFF — so users know there's something to
|
||
// re-surface.
|
||
hiddenCount?: number;
|
||
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
|
||
// engine suppressed because their `trigger_event_id` anchor wasn't
|
||
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
|
||
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
|
||
// "N rules waiting on an anchor" UI affordances.
|
||
rulesAwaitingAnchor?: number;
|
||
}
|
||
|
||
export interface CourtRow {
|
||
id: string;
|
||
code: string;
|
||
nameDE: string;
|
||
nameEN: string;
|
||
country: string;
|
||
regime?: string;
|
||
courtType: string;
|
||
}
|
||
|
||
export interface CalcParams {
|
||
proceedingType: string;
|
||
triggerDate: string;
|
||
priorityDate?: string;
|
||
flags?: string[];
|
||
anchorOverrides?: Record<string, string>;
|
||
courtId?: string;
|
||
// t-paliad-265: per-event-card choices. Either pass `projectId` for
|
||
// server-side lookup against paliad.project_event_choices, OR pass
|
||
// an inline list (for the unbound /tools/verfahrensablauf surface).
|
||
// When both are supplied the inline list wins server-side.
|
||
projectId?: string;
|
||
perCardChoices?: Array<{
|
||
submission_code: string;
|
||
choice_kind: string;
|
||
choice_value: string;
|
||
}>;
|
||
// includeHidden (t-paliad-290): when true the calculator returns
|
||
// previously-skipped rules as faded cards instead of dropping them.
|
||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||
// ON.
|
||
includeHidden?: boolean;
|
||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||
// contains the requested slug. Empty = no filter. Valid values:
|
||
// endentscheidung | kostenentscheidung | anordnung |
|
||
// schadensbemessung | bucheinsicht.
|
||
appealTarget?: string;
|
||
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
|
||
// axes to the HTTP boundary:
|
||
//
|
||
// includeOptional: when true, the engine returns priority='optional'
|
||
// rules in the timeline. Default false matches the engine default
|
||
// (mandatory backbone only). The /tools/procedures detailgrad
|
||
// toggle ("all_options" mode) drives this to true so the dimmed
|
||
// optional cards can be rendered for the lawyer to opt into.
|
||
// triggerEventAnchors: per-event-code anchor dates the engine
|
||
// consults for rules carrying trigger_event_id. Empty/omitted =
|
||
// no anchors → such rules render as IsConditional (the engine
|
||
// refuses to fabricate a date off the proceeding's trigger date).
|
||
includeOptional?: boolean;
|
||
triggerEventAnchors?: Record<string, string>;
|
||
}
|
||
|
||
const PARTY_CLASS: Record<string, string> = {
|
||
claimant: "party-claimant",
|
||
defendant: "party-defendant",
|
||
court: "party-court",
|
||
both: "party-both",
|
||
};
|
||
|
||
// ─── small helpers ─────────────────────────────────────────────────────────
|
||
|
||
export function escAttr(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||
}
|
||
|
||
// Pure-string HTML escape — keeps the module testable in bun test
|
||
// (plain Node, no jsdom). Used to be backed by document.createElement,
|
||
// which forced fixtures to leave any field that flowed through it
|
||
// empty just to exercise unrelated branches; the regex form is safe
|
||
// for arbitrary text including the per-rule name strings that the
|
||
// conditional-row chip ("abhängig von <parent>") now exposes.
|
||
// (t-paliad-289)
|
||
export function escHtml(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
export function formatDate(dateStr: string): string {
|
||
if (!dateStr) return "—";
|
||
const d = new Date(dateStr + "T00:00:00");
|
||
if (getLang() === "en") {
|
||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||
const yyyy = d.getFullYear();
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||
}
|
||
return d.toLocaleDateString("de-DE", {
|
||
weekday: "short",
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
|
||
function formatDateSpan(startISO: string, endISO: string): string {
|
||
const start = new Date(startISO + "T00:00:00");
|
||
const end = new Date(endISO + "T00:00:00");
|
||
if (getLang() === "en") {
|
||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||
return `${fmt(start)} – ${fmt(end)}`;
|
||
}
|
||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||
return `${fmt(start)}–${fmt(end)}`;
|
||
}
|
||
|
||
function localizeWeekday(en: string): string {
|
||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||
return en;
|
||
}
|
||
|
||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||
// vacation"). Not translated — they're proper names of court-set closures.
|
||
function localizeVacationName(name: string): string {
|
||
return name;
|
||
}
|
||
|
||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||
return tDyn("deadlines.adjusted.vacation")
|
||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||
.replace("{span}", span);
|
||
}
|
||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||
}
|
||
if (r.kind === "weekend" && r.original_weekday) {
|
||
return localizeWeekday(r.original_weekday);
|
||
}
|
||
return t("deadlines.adjusted.weekend");
|
||
}
|
||
|
||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||
const reason = dl.adjustmentReason
|
||
? renderAdjustmentReason(dl.adjustmentReason)
|
||
: t("deadlines.adjusted.reason");
|
||
if (getLang() === "en") {
|
||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||
}
|
||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||
}
|
||
|
||
export function partyBadge(party: string): string {
|
||
const cls = PARTY_CLASS[party] || "party-both";
|
||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||
}
|
||
|
||
// ─── card + body renderers ────────────────────────────────────────────────
|
||
|
||
export interface CardOpts {
|
||
showParty: boolean;
|
||
// editable=true wires the click-to-edit affordance: data-rule-code,
|
||
// role=button, tabindex, hover hint. Fristenrechner enables it; the
|
||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||
// there's no anchor-override state on that page in Slice 1.
|
||
editable?: boolean;
|
||
// showNotes controls how the per-rule descriptive notes render:
|
||
// true → expanded `<div class="timeline-notes">…</div>` below the card
|
||
// false → compact ⓘ icon next to the meta line, full text on hover
|
||
// (browser-native `title` attribute) and screen-reader-readable
|
||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||
// re-renders. Default false — notes are noisy on long timelines.
|
||
showNotes?: boolean;
|
||
// showDurations controls per-rule duration rendering on event cards
|
||
// (m/paliad#133, t-paliad-302):
|
||
// true → inline `<span class="timeline-duration">2 Mo. nach</span>`
|
||
// next to the date.
|
||
// false → hover-only tooltip on the date span (browser-native
|
||
// `title` attribute). Cards without a usable
|
||
// `durationValue > 0` get neither — court-set and trigger-
|
||
// event cards have no explainable interval.
|
||
// /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
|
||
// flips this and re-renders; persisted via the localStorage key
|
||
// `paliad.verfahrensablauf.durations-show`. Default false.
|
||
showDurations?: boolean;
|
||
// triggerEventLabel: per-language label of the proceeding's anchor
|
||
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
|
||
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
|
||
// as the parent-name fallback when a rule is a root rule (no
|
||
// parent_id) but carries a non-zero duration — e.g. the
|
||
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
|
||
// already-language-resolved string. (t-paliad-307 / m/paliad#136
|
||
// Bug 3)
|
||
triggerEventLabel?: string;
|
||
}
|
||
|
||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||
const wantsEditable = !!opts.editable;
|
||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||
// Parent name for the duration label (t-paliad-307 / m/paliad#136
|
||
// Bug 3): use the rule's parent if set, else fall back to the
|
||
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
|
||
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
|
||
// Empty for rules whose anchor isn't surface-able — the duration
|
||
// label degrades to the bare "<n> <unit> <timing>" form in that case.
|
||
const parentLabelForDuration = (getLang() === "en"
|
||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
|
||
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
|
||
// both the date-span tooltip and the inline meta-row span pull from
|
||
// the same string. Empty for rules without a usable duration.
|
||
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
|
||
// Hover affordance on the date span: prefer the duration tooltip when
|
||
// we have one, else fall back to the edit-hint when the cell is
|
||
// click-to-edit. The edit affordance still works either way — the
|
||
// title is purely advisory.
|
||
const dateTitle = durationLabel
|
||
? durationLabel
|
||
: (editable ? t("deadlines.date.edit.hint") : "");
|
||
const editAttrs = editable
|
||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
|
||
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
|
||
// Conditional rows (t-paliad-289) replace the date column with an
|
||
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||
// the user can pin a real date once known (e.g. once the oral
|
||
// hearing date is set, or the opposing party's Vertraulichkeits-
|
||
// antrag arrives) — the same data-rule-code wiring fires the
|
||
// existing inline date editor. IsConditional wins over IsCourtSet:
|
||
// they overlap (court-set ancestor without override produces both),
|
||
// and "abhängig von <parent>" is the clearer user-facing signal.
|
||
const parentLabel = (getLang() === "en"
|
||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||
: dl.parentRuleName) || "";
|
||
let dateStr: string;
|
||
if (dl.isConditional) {
|
||
const chipText = parentLabel
|
||
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
|
||
: t("deadlines.conditional.unset");
|
||
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
|
||
} else if (dl.isCourtSet) {
|
||
const courtLabelKey = dl.isCourtSetIndirect
|
||
? "deadlines.court.indirect"
|
||
: "deadlines.court.set";
|
||
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
|
||
} else {
|
||
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||
}
|
||
|
||
// t-paliad-293 — iconified state markers. The card surface speaks
|
||
// "cut the tree of possibilities": each card carries 0–N small icons
|
||
// in the title row that summarise its decision state at a glance.
|
||
// The text "optional" badge that used to sit inline next to the name
|
||
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
|
||
// marker. Conditional cards already have the date-column chip; the
|
||
// marker is redundant in the title row. CCR-included / appellant
|
||
// picks remain on the chip row (event-card-choices-chip) — see below.
|
||
// Tooltips are i18n-driven so they read in the user's language.
|
||
const stateIcons: string[] = [];
|
||
if (dl.priority === "optional") {
|
||
stateIcons.push(
|
||
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
|
||
);
|
||
}
|
||
if (dl.isHidden) {
|
||
stateIcons.push(
|
||
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
|
||
);
|
||
}
|
||
const stateIconsHtml = stateIcons.join("");
|
||
|
||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||
// offers per-card choices and the user has made a pick. The popover
|
||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||
// the data-* attributes here are the wire contract between the two.
|
||
//
|
||
// t-paliad-293 — hidden cards always expose the caret so the user
|
||
// can un-hide via the popover's "Wieder einblenden" entry. Normally
|
||
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
|
||
// is present. Defensive fallback: if a rule's `choices_offered` was
|
||
// edited away after the skip entry was saved, the user would lose
|
||
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
|
||
// offer for the popover in that edge case so the prominent
|
||
// "Wieder einblenden" button still renders.
|
||
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
|
||
? dl.choicesOffered
|
||
: (dl.isHidden ? { skip: [true, false] } : null);
|
||
const showCaret = dl.code !== "" && offeredForCaret !== null;
|
||
const choicesHtml = showCaret
|
||
? `<button type="button" class="event-card-choices-caret"
|
||
data-submission-code="${escAttr(dl.code)}"
|
||
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
|
||
data-is-hidden="${dl.isHidden ? "1" : "0"}"
|
||
aria-label="${escAttr(t("choices.caret.title"))}"
|
||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||
: "";
|
||
|
||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||
|
||
const adjustedNote = dl.wasAdjusted
|
||
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
|
||
: "";
|
||
|
||
// Prefer the structured legalSource (pretty display + youpc.org link
|
||
// when hosted there) over the bare rule_code fallback. UPC.RoP rules
|
||
// link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
|
||
// yet so we render display text plain.
|
||
const legalDisplay = dl.legalSourceDisplay || "";
|
||
const legalURL = dl.legalSourceURL || "";
|
||
let ruleRef = "";
|
||
if (legalDisplay && legalURL) {
|
||
ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
|
||
} else if (legalDisplay) {
|
||
ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
|
||
} else if (dl.ruleRef) {
|
||
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
|
||
}
|
||
|
||
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||
// Strip the leading-duration prefix so the new duration affordance
|
||
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
|
||
// for those legacy rule rows that still carry it.
|
||
// (t-paliad-307 / m/paliad#136 Bug 4)
|
||
const noteText = rawNoteText
|
||
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
|
||
: rawNoteText;
|
||
const showNotes = opts.showNotes === true;
|
||
const notesBlock = noteText && showNotes
|
||
? `<div class="timeline-notes">${noteText}</div>`
|
||
: "";
|
||
const noteHint = noteText && !showNotes
|
||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||
: "";
|
||
|
||
// Inline duration affordance (m/paliad#133, t-paliad-302). Only
|
||
// emitted when the "Dauern anzeigen" toggle is on AND the rule has a
|
||
// usable duration; the default-off hover-tooltip path is wired
|
||
// separately on the date span itself.
|
||
const showDurations = opts.showDurations === true;
|
||
const durationInline = showDurations && durationLabel
|
||
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
|
||
: "";
|
||
|
||
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
|
||
? `<div class="timeline-meta">
|
||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||
${durationInline}
|
||
${ruleRef}
|
||
${noteHint}
|
||
</div>`
|
||
: "";
|
||
|
||
// Chip indicator surfaces the active per-card pick (t-paliad-265).
|
||
// The popover module rehydrates this on commit so it stays in sync.
|
||
const chipHtml = dl.code !== ""
|
||
? `<span class="event-card-choices-chip"
|
||
data-submission-code="${escAttr(dl.code)}"
|
||
data-empty="true"></span>`
|
||
: "";
|
||
|
||
// m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional /
|
||
// recommended rules (when the detail-mode filter is in "all_options"
|
||
// or "selected"). The detail-mode filter tags unselected rules with
|
||
// __detailUnselected; the renderer picks that up to render the chip
|
||
// in its "Aufnehmen" state. Mandatory rules never get the chip — the
|
||
// user can't deselect them.
|
||
const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true;
|
||
let selectionChip = "";
|
||
if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) {
|
||
if (detailUnselected) {
|
||
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
|
||
data-rule-id="${escAttr(dl.ruleId)}"
|
||
data-priority="${escAttr(dl.priority)}"
|
||
data-action="aufnehmen"
|
||
title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
|
||
${escHtml(t("deadlines.detail.aufnehmen"))}
|
||
</button>`;
|
||
} else if (dl.priority === "recommended" || dl.priority === "optional") {
|
||
// The rule IS in the active scenario but can be removed. Renders
|
||
// as a discreet [Entfernen] chip on optional / recommended cards.
|
||
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
|
||
data-rule-id="${escAttr(dl.ruleId)}"
|
||
data-priority="${escAttr(dl.priority)}"
|
||
data-action="entfernen">
|
||
${escHtml(t("deadlines.detail.entfernen"))}
|
||
</button>`;
|
||
}
|
||
}
|
||
|
||
return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
|
||
<span class="timeline-name">
|
||
${dlName}
|
||
${stateIconsHtml}
|
||
${chipHtml}
|
||
</span>
|
||
${dateStr}
|
||
${selectionChip}
|
||
${choicesHtml}
|
||
</div>
|
||
${meta}
|
||
${adjustedNote}
|
||
${notesBlock}`;
|
||
}
|
||
|
||
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||
//
|
||
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||
// result container once, and the delegated click/keydown handlers swap a
|
||
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||
// newValue means "revert" (clear the anchor override and let the calculator
|
||
// re-project). The actual recompute is the caller's job — they own the
|
||
// anchor-overrides map + the calc dispatch.
|
||
|
||
export function openInlineDateEditor(
|
||
span: HTMLElement,
|
||
onCommit: (ruleCode: string, newValue: string) => void,
|
||
): void {
|
||
const ruleCode = span.dataset.ruleCode || "";
|
||
if (!ruleCode) return;
|
||
const current = span.dataset.currentDate || "";
|
||
const editor = document.createElement("input");
|
||
editor.type = "date";
|
||
editor.className = "frist-date-edit-input";
|
||
editor.value = current;
|
||
|
||
let done = false;
|
||
const cancel = () => {
|
||
if (done) return;
|
||
done = true;
|
||
editor.replaceWith(span);
|
||
};
|
||
const commit = (newValue: string) => {
|
||
if (done) return;
|
||
done = true;
|
||
onCommit(ruleCode, newValue);
|
||
};
|
||
|
||
editor.addEventListener("blur", () => {
|
||
if (editor.value !== current) commit(editor.value);
|
||
else cancel();
|
||
});
|
||
editor.addEventListener("keydown", (e) => {
|
||
const ke = e as KeyboardEvent;
|
||
if (ke.key === "Enter") {
|
||
e.preventDefault();
|
||
editor.blur();
|
||
} else if (ke.key === "Escape") {
|
||
e.preventDefault();
|
||
cancel();
|
||
}
|
||
});
|
||
|
||
span.replaceWith(editor);
|
||
editor.focus();
|
||
if (editor.value) editor.select();
|
||
}
|
||
|
||
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||
// timeline result container so click-to-edit survives every innerHTML
|
||
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||
// container does nothing (the dataset flag short-circuits).
|
||
export function wireDateEditClicks(
|
||
container: HTMLElement,
|
||
onCommit: (ruleCode: string, newValue: string) => void,
|
||
): void {
|
||
if (container.dataset.dateEditWired === "1") return;
|
||
container.dataset.dateEditWired = "1";
|
||
container.addEventListener("click", (e) => {
|
||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||
if (!target || !target.dataset.ruleCode) return;
|
||
openInlineDateEditor(target, onCommit);
|
||
});
|
||
container.addEventListener("keydown", (e) => {
|
||
const ke = e as KeyboardEvent;
|
||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||
if (!target || !target.dataset.ruleCode) return;
|
||
e.preventDefault();
|
||
openInlineDateEditor(target, onCommit);
|
||
});
|
||
}
|
||
|
||
// pickTriggerEventLabel returns the per-language trigger event label
|
||
// from a DeadlineResponse, used as the parent-fallback for root-rule
|
||
// duration labels. Mirrors the precedence the page-level
|
||
// triggerEventLabelFor uses (curated server label > proceedingName
|
||
// fallback). Distinct from the page helper in that it stays language-
|
||
// scoped to the current getLang() — root-rule duration labels render
|
||
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
|
||
export function pickTriggerEventLabel(data: DeadlineResponse): string {
|
||
const lang = getLang();
|
||
const curated = lang === "en"
|
||
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
|
||
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
|
||
if (curated) return curated;
|
||
return lang === "en"
|
||
? (data.proceedingNameEN || data.proceedingName || "")
|
||
: (data.proceedingName || data.proceedingNameEN || "");
|
||
}
|
||
|
||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||
// Resolve the trigger event label once so the duration affordance on
|
||
// root rules (no parent) can read it as the anchor fallback. Caller-
|
||
// provided value wins (lets the page override for sub-track flows).
|
||
const cardOpts: CardOpts = {
|
||
...opts,
|
||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||
};
|
||
let html = '<div class="timeline">';
|
||
for (const dl of data.deadlines) {
|
||
const itemClasses = [
|
||
"timeline-item",
|
||
dl.isRootEvent ? "timeline-root" : "",
|
||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||
// shared timeline-item--hidden modifier (same modifier the columns
|
||
// view uses; see fr-col-item--hidden below).
|
||
dl.isHidden ? "timeline-item--hidden" : "",
|
||
// t-paliad-289: dotted-border + faded styling for conditional rows
|
||
// so the "abhängig von <parent>" state is visually distinct from
|
||
// both anchored deadlines and direct court-set rows.
|
||
dl.isConditional ? "timeline-item--conditional" : "",
|
||
].filter(Boolean).join(" ");
|
||
html += `
|
||
<div class="${itemClasses}">
|
||
<div class="timeline-dot-col">
|
||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||
<div class="timeline-line"></div>
|
||
</div>
|
||
<div class="timeline-content">
|
||
${deadlineCardHtml(dl, cardOpts)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
html += "</div>";
|
||
return html;
|
||
}
|
||
|
||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||
//
|
||
// The columns are user-perspective ("WE are always on the left", per
|
||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||
// placement re-resolves around that pick.
|
||
//
|
||
// Column assignment per deadline (default opts.side === null keeps
|
||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||
//
|
||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||
// - party=court → court (independent of side)
|
||
// - party=both → BOTH ours AND opponent (mirror)
|
||
//
|
||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||
// collapse to a single row in the appellant's column — the intent is
|
||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||
// really means "either party files, depending on who initiated".
|
||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||
// appellant selector pins which party appealed; the side toggle
|
||
// still picks which of those is us.
|
||
export type Side = "claimant" | "defendant" | null;
|
||
|
||
// Internal column-position alias. "ours" is always rendered in the
|
||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||
// bucketing primitive reads as a direct mapping.
|
||
type ColumnPosition = "ours" | "opponent";
|
||
|
||
export interface ColumnsBodyOpts {
|
||
editable?: boolean;
|
||
showNotes?: boolean;
|
||
// Forwarded to deadlineCardHtml — see CardOpts.showDurations.
|
||
// (m/paliad#133, t-paliad-302)
|
||
showDurations?: boolean;
|
||
// side: which side the user is on. Drives column placement;
|
||
// does NOT filter rows. Default null = claimant-on-the-left
|
||
// (i.e. "ours = claimant", legacy default).
|
||
side?: Side;
|
||
// appellant: which side initiated the appeal / counterclaim.
|
||
// When set, party=both rows go to the appellant's column ONLY
|
||
// (no mirror). Default null = mirror "both" into both cells
|
||
// (legacy behaviour). Independent of `side`.
|
||
appellant?: Side;
|
||
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
|
||
// page is rendering an appeal_target-filtered timeline. Routes
|
||
// each rule to its filer-perspective column via dl.appealRole
|
||
// instead of the legacy primary_party='both' collapse.
|
||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||
appealAware?: boolean;
|
||
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
|
||
// (t-paliad-307 / m/paliad#136 Bug 3)
|
||
triggerEventLabel?: string;
|
||
}
|
||
|
||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||
// so unit tests can hit the pure routing logic without going through
|
||
// document.createElement (no jsdom in this repo).
|
||
export interface ColumnsRow {
|
||
key: string;
|
||
ours: CalculatedDeadline[];
|
||
court: CalculatedDeadline[];
|
||
opponent: CalculatedDeadline[];
|
||
}
|
||
|
||
export interface BucketingOpts {
|
||
side?: Side;
|
||
appellant?: Side;
|
||
// appealAware: when true, rules carrying a `dl.appealRole` of
|
||
// "appellant" / "appellee" route via the appeal role + user side
|
||
// axis instead of the legacy primary_party='both' collapse. With
|
||
// `side=null` the bucketer keeps the mirror semantic (both columns
|
||
// render every appeal rule); with `side` set, "appellant" rules
|
||
// land in the user's column when the user IS the appellant, in
|
||
// the opponent's column otherwise — mirror for "appellee" rules.
|
||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||
appealAware?: boolean;
|
||
}
|
||
|
||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||
// column placement (including the side-swap + appellant-collapse
|
||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||
// declaration order (each keyed by sequence).
|
||
export function bucketDeadlinesIntoColumns(
|
||
deadlines: CalculatedDeadline[],
|
||
opts: BucketingOpts = {},
|
||
): ColumnsRow[] {
|
||
const userSide: Side = opts.side ?? null;
|
||
// Default (side=null) treats the user as claimant — keeps the
|
||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||
const appellantColumn: ColumnPosition | null =
|
||
opts.appellant === "claimant" ? claimantColumn
|
||
: opts.appellant === "defendant" ? defendantColumn
|
||
: null;
|
||
|
||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||
const rowsMap = new Map<string, ColumnsRow>();
|
||
const ensureRow = (key: string): ColumnsRow => {
|
||
let r = rowsMap.get(key);
|
||
if (!r) {
|
||
r = { key, ours: [], court: [], opponent: [] };
|
||
rowsMap.set(key, r);
|
||
}
|
||
return r;
|
||
};
|
||
|
||
const appealAware = opts.appealAware === true;
|
||
|
||
deadlines.forEach((dl, idx) => {
|
||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||
const row = ensureRow(key);
|
||
switch (dl.party) {
|
||
case "claimant":
|
||
row[claimantColumn].push(dl);
|
||
break;
|
||
case "defendant":
|
||
row[defendantColumn].push(dl);
|
||
break;
|
||
case "court":
|
||
row.court.push(dl);
|
||
break;
|
||
case "both":
|
||
// t-paliad-265: a per-card appellant set on a decision
|
||
// ancestor propagates as appellantContext on this rule. When
|
||
// present, it overrides the page-level appellant for the
|
||
// collapse decision on THIS row. Falls through to page-level
|
||
// when empty.
|
||
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
|
||
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
|
||
row[perCardCol].push(dl);
|
||
} else if (
|
||
appealAware &&
|
||
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
|
||
) {
|
||
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
|
||
// With no side picked, mirror to both columns so every rule
|
||
// is visible regardless of which side the user is on. With
|
||
// a side picked, route by (filer matches user) → ours
|
||
// column, else opponent column. side=claimant maps the
|
||
// user to "appellant" (Berufungskläger); side=defendant
|
||
// maps the user to "appellee" (Berufungsbeklagter).
|
||
if (userSide === null) {
|
||
row.ours.push(dl);
|
||
row.opponent.push(dl);
|
||
} else {
|
||
const userIsAppellant = userSide === "claimant";
|
||
const filerIsAppellant = dl.appealRole === "appellant";
|
||
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
|
||
}
|
||
} else if (appellantColumn !== null) {
|
||
// Role-swap collapse: appellant initiated → both → one row
|
||
// in appellant's column. Mirror suppressed.
|
||
row[appellantColumn].push(dl);
|
||
} else if (userSide !== null) {
|
||
// Side picked but no appellant axis (first-instance Inf, Rev,
|
||
// …): the user has committed to a perspective, so the mirror
|
||
// is visual noise — the same card appears twice on the same
|
||
// row, once in "Unsere Seite" and once in "Gegnerseite".
|
||
// Collapse into ours; the "↔ beide Seiten" indicator on the
|
||
// card already conveys that the rule applies to both parties.
|
||
// (m/paliad#135 / t-paliad-304)
|
||
row.ours.push(dl);
|
||
} else {
|
||
// No perspective picked → keep the legacy mirror so neither
|
||
// axis is privileged. Pinned by the "default (no opts)" test.
|
||
row.ours.push(dl);
|
||
row.opponent.push(dl);
|
||
}
|
||
break;
|
||
default:
|
||
row.court.push(dl);
|
||
}
|
||
});
|
||
|
||
const datedKeys: string[] = [];
|
||
const unscheduledKeys: string[] = [];
|
||
for (const k of rowsMap.keys()) {
|
||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||
else datedKeys.push(k);
|
||
}
|
||
datedKeys.sort();
|
||
unscheduledKeys.sort();
|
||
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
|
||
}
|
||
|
||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||
const userSide: Side = opts.side ?? null;
|
||
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
|
||
side: userSide,
|
||
appellant: opts.appellant,
|
||
appealAware: opts.appealAware,
|
||
});
|
||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||
|
||
const cardOpts: CardOpts = {
|
||
showParty: false,
|
||
editable: opts.editable,
|
||
showNotes: opts.showNotes,
|
||
showDurations: opts.showDurations,
|
||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||
};
|
||
|
||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||
// be misleading. Both collapse paths suppress it:
|
||
// - appellantPinned: role-swap collapse into appellant's column
|
||
// - userSide !== null without appellantPinned: perspective-locked
|
||
// collapse into ours (m/paliad#135 / t-paliad-304).
|
||
// Legacy mirror path (no side, no appellant) keeps the tag — both
|
||
// sibling rows still render so the tag has a visual referent.
|
||
const sideCollapse = userSide !== null;
|
||
const showMirrorTag = !appellantPinned && !sideCollapse;
|
||
|
||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||
if (items.length === 0) {
|
||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||
}
|
||
const cards = items
|
||
.map((dl) => {
|
||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||
: "";
|
||
const itemClasses = [
|
||
"fr-col-item",
|
||
dl.isRootEvent ? "fr-col-root" : "",
|
||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||
// shared fr-col-item--hidden modifier.
|
||
dl.isHidden ? "fr-col-item--hidden" : "",
|
||
// t-paliad-289: same conditional treatment as the linear
|
||
// timeline-item — dotted border + faded styling.
|
||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||
].filter(Boolean).join(" ");
|
||
// data-rule-id on the card root lets the Litigation Builder
|
||
// overlay per-card state (planned/filed/skipped) + action
|
||
// affordances onto cards rendered through this shared body
|
||
// without re-implementing the columns renderer. Empty on
|
||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||
// skips state lookup when missing.
|
||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||
${deadlineCardHtml(dl, cardOpts)}
|
||
${mirrorTag}
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
return `<div class="fr-col-cell">${cards}</div>`;
|
||
};
|
||
|
||
const headerCell = (label: string, cls: string) =>
|
||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||
|
||
// Column-header labels have two modes (m/paliad#127):
|
||
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
|
||
// truthfully describe whose filings sit there,
|
||
// because the bucketer routed the user's side into
|
||
// `ours`).
|
||
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
|
||
// user-perspective labels would lie here: we don't
|
||
// know yet which party is "us", so calling the left
|
||
// column "Unsere Seite" presumes a pick the user
|
||
// hasn't made. The neutral Proaktiv/Reaktiv pair
|
||
// keeps the spatial axis ("who initiates vs who
|
||
// responds") legible while the hint chip on the
|
||
// page nudges the user to pick a side.
|
||
//
|
||
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
|
||
// still routes claimant→left, defendant→right when side=null (legacy
|
||
// claimant-on-the-left fallback). Only the HEADER label changes.
|
||
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
|
||
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
|
||
let html = '<div class="fr-columns-view">';
|
||
html += headerCell(leftLabel, "fr-col-ours");
|
||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||
html += headerCell(rightLabel, "fr-col-opponent");
|
||
|
||
for (const row of rows) {
|
||
html += renderCell(row.ours);
|
||
html += renderCell(row.court);
|
||
html += renderCell(row.opponent);
|
||
}
|
||
html += "</div>";
|
||
return html;
|
||
}
|
||
|
||
// ─── calculate fetch wrapper ──────────────────────────────────────────────
|
||
|
||
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
|
||
if (!params.proceedingType || !params.triggerDate) return null;
|
||
try {
|
||
const resp = await fetch("/api/tools/fristenrechner", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
proceedingType: params.proceedingType,
|
||
triggerDate: params.triggerDate,
|
||
priorityDate: params.priorityDate || undefined,
|
||
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
|
||
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
|
||
? params.anchorOverrides
|
||
: undefined,
|
||
courtId: params.courtId || undefined,
|
||
projectId: params.projectId || undefined,
|
||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||
? params.perCardChoices
|
||
: undefined,
|
||
includeHidden: params.includeHidden ? true : undefined,
|
||
appealTarget: params.appealTarget || undefined,
|
||
includeOptional: params.includeOptional ? true : undefined,
|
||
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
|
||
? params.triggerEventAnchors
|
||
: undefined,
|
||
}),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
console.error("API error:", err);
|
||
return null;
|
||
}
|
||
return (await resp.json()) as DeadlineResponse;
|
||
} catch (e) {
|
||
console.error("Fetch error:", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── court picker ─────────────────────────────────────────────────────────
|
||
|
||
const courtCache = new Map<string, CourtRow[]>();
|
||
|
||
export function courtTypesFor(proceedingType: string): string[] {
|
||
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
|
||
return ["UPC-CoA"];
|
||
}
|
||
if (proceedingType === "upc.rev.cfi") {
|
||
return ["UPC-CD", "UPC-LD"];
|
||
}
|
||
if (proceedingType.startsWith("upc.")) {
|
||
return ["UPC-LD"];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
export function defaultCourtFor(proceedingType: string): string {
|
||
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
|
||
return "upc-coa-luxembourg";
|
||
}
|
||
if (proceedingType === "upc.rev.cfi") {
|
||
return "upc-cd-paris";
|
||
}
|
||
return "upc-ld-muenchen";
|
||
}
|
||
|
||
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||
try {
|
||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||
if (!resp.ok) return [];
|
||
const rows = (await resp.json()) as CourtRow[];
|
||
courtCache.set(courtType, rows);
|
||
return rows;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// populateCourtPicker fills the <select> for the proceeding's compatible
|
||
// court types. The row + select IDs are passed in so each page can own
|
||
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
|
||
// courts; otherwise hidden (server resolves the jurisdiction default).
|
||
export async function populateCourtPicker(
|
||
rowId: string,
|
||
selectId: string,
|
||
proceedingType: string,
|
||
): Promise<void> {
|
||
const row = document.getElementById(rowId);
|
||
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
||
if (!row || !select) return;
|
||
|
||
const types = courtTypesFor(proceedingType);
|
||
if (types.length === 0) {
|
||
row.style.display = "none";
|
||
select.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
|
||
const courts = lists.flat();
|
||
if (courts.length <= 1) {
|
||
row.style.display = "none";
|
||
select.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
const lang = getLang();
|
||
const defaultID = defaultCourtFor(proceedingType);
|
||
select.innerHTML = courts.map((c) => {
|
||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||
}).join("");
|
||
row.style.display = "";
|
||
}
|