// 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:` 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; // 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 " chip instead. isConditional?: boolean; // parentRuleCode / parentRuleName / parentRuleNameEN surface the // parent rule's identity so the renderer can label the // "abhängig von " 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 ." (DE) / // "N ." (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..{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 // " " — 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; 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; } const PARTY_CLASS: Record = { 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 ") now exposes. // (t-paliad-289) export function escHtml(s: string): string { return s .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 `${tDyn("deadlines.party." + party)}`; } // ─── 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 `
` 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 `2 Mo. nach` // 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 " " 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 " 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 " 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 = `${chipText}`; } else if (dl.isCourtSet) { const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set"; dateStr = `${t(courtLabelKey)}`; } else { dateStr = `${formatDate(dl.dueDate)}`; } // 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( ``, ); } if (dl.isHidden) { stateIcons.push( `👁⃠`, ); } 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 ? `` : ""; const dlName = getLang() === "en" ? dl.nameEN : dl.name; const adjustedNote = dl.wasAdjusted ? `
⚠ ${formatAdjustedNote(dl)}
` : ""; // 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/; 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 = `${escHtml(legalDisplay)}`; } else if (legalDisplay) { ruleRef = `${escHtml(legalDisplay)}`; } else if (dl.ruleRef) { ruleRef = `${escHtml(dl.ruleRef)}`; } 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 ? `
${noteText}
` : ""; const noteHint = noteText && !showNotes ? `` : ""; // 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 ? `${escHtml(durationLabel)}` : ""; const meta = (opts.showParty || ruleRef || noteHint || durationInline) ? `
${opts.showParty ? partyBadge(dl.party) : ""} ${durationInline} ${ruleRef} ${noteHint}
` : ""; // 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 !== "" ? `` : ""; // 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 = ``; } 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 = ``; } } return `
${dlName} ${stateIconsHtml} ${chipHtml} ${dateStr} ${selectionChip} ${choicesHtml}
${meta} ${adjustedNote} ${notesBlock}`; } // ─── inline date editor (click-to-edit per-rule due date) ──────────────── // // The renderer emits `` 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 `` 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(".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(".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 = '
'; 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 " state is visually distinct from // both anchored deadlines and direct court-set rows. dl.isConditional ? "timeline-item--conditional" : "", ].filter(Boolean).join(" "); html += `
${deadlineCardHtml(dl, cardOpts)}
`; } html += "
"; 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(); 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 `
`; } const cards = items .map((dl) => { const mirrorTag = showMirrorTag && dl.party === "both" ? `
↔ ${escHtml(t("deadlines.party.both.label"))}
` : ""; 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 `
${deadlineCardHtml(dl, cardOpts)} ${mirrorTag}
`; }) .join(""); return `
${cards}
`; }; const headerCell = (label: string, cls: string) => `
${escHtml(label)}
`; // 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 = '
'; 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 += "
"; return html; } // ─── calculate fetch wrapper ────────────────────────────────────────────── export async function calculateDeadlines(params: CalcParams): Promise { 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(); 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 { 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