999 lines
42 KiB
TypeScript
999 lines
42 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 {
|
||
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;
|
||
}
|
||
|
||
// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for
|
||
// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302).
|
||
// 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.
|
||
export function formatDurationLabel(dl: CalculatedDeadline): 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}`) : "";
|
||
return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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" : "";
|
||
// 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);
|
||
// 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 noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||
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>`
|
||
: "";
|
||
|
||
return `<div class="timeline-item-header">
|
||
<span class="timeline-name">
|
||
${dlName}
|
||
${stateIconsHtml}
|
||
${chipHtml}
|
||
</span>
|
||
${dateStr}
|
||
${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);
|
||
});
|
||
}
|
||
|
||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||
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, opts)}
|
||
</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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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;
|
||
};
|
||
|
||
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 (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 });
|
||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||
|
||
const cardOpts: CardOpts = {
|
||
showParty: false,
|
||
editable: opts.editable,
|
||
showNotes: opts.showNotes,
|
||
showDurations: opts.showDurations,
|
||
};
|
||
|
||
// 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(" ");
|
||
return `<div class="${itemClasses}">
|
||
${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,
|
||
}),
|
||
});
|
||
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 = "";
|
||
}
|