feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)

Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
This commit is contained in:
mAi
2026-05-18 14:58:17 +02:00
parent 7d275cac6b
commit a18b825bee
10 changed files with 337 additions and 17 deletions

View File

@@ -112,23 +112,29 @@ async function calculate() {
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null; const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : ""; const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
// Flags — three proceeding-specific checkboxes: // Flags — proceeding-specific checkboxes:
// upc.inf.cfi: with_ccr (always available); with_amend (nested under // upc.inf.cfi: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR). // with_ccr — R.30 application is only available with a CCR);
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two // with_po (mig 095, R.19.1 preliminary objection).
// independent gates; both can be on simultaneously. // upc.rev.cfi: with_amend (R.49.2.a), with_cci (R.49.2.b),
// with_po (R.19.1 via R.46) as three independent gates;
// all combinations are valid.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null; const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null; const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const infPoFlag = document.getElementById("inf-po-flag") as HTMLInputElement | null;
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null; const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null; const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const revPoFlag = document.getElementById("rev-po-flag") as HTMLInputElement | null;
const flags: string[] = []; const flags: string[] = [];
if (selectedType === "upc.inf.cfi") { if (selectedType === "upc.inf.cfi") {
if (ccrFlag?.checked) flags.push("with_ccr"); if (ccrFlag?.checked) flags.push("with_ccr");
if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend"); if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend");
if (infPoFlag?.checked) flags.push("with_po");
} }
if (selectedType === "upc.rev.cfi") { if (selectedType === "upc.rev.cfi") {
if (revAmendFlag?.checked) flags.push("with_amend"); if (revAmendFlag?.checked) flags.push("with_amend");
if (revCciFlag?.checked) flags.push("with_cci"); if (revCciFlag?.checked) flags.push("with_cci");
if (revPoFlag?.checked) flags.push("with_po");
} }
// Forward any user-set per-rule date overrides so downstream rules // Forward any user-set per-rule date overrides so downstream rules
@@ -508,18 +514,24 @@ function selectProceeding(btn: HTMLButtonElement) {
// ccr-flag → upc.inf.cfi only // ccr-flag → upc.inf.cfi only
// inf-amend-flag → upc.inf.cfi only, but disabled until ccr-flag is on // inf-amend-flag → upc.inf.cfi only, but disabled until ccr-flag is on
// (R.30 amend only available with a CCR) // (R.30 amend only available with a CCR)
// inf-po-flag → upc.inf.cfi only (R.19.1 preliminary objection, mig 095)
// rev-amend-flag → upc.rev.cfi only // rev-amend-flag → upc.rev.cfi only
// rev-cci-flag → upc.rev.cfi only // rev-cci-flag → upc.rev.cfi only
// rev-po-flag → upc.rev.cfi only (R.19.1 via R.46, mig 095)
const priorityRow = document.getElementById("priority-date-row"); const priorityRow = document.getElementById("priority-date-row");
if (priorityRow) priorityRow.style.display = selectedType === "epa.grant.exa" ? "" : "none"; if (priorityRow) priorityRow.style.display = selectedType === "epa.grant.exa" ? "" : "none";
const ccrRow = document.getElementById("ccr-flag-row"); const ccrRow = document.getElementById("ccr-flag-row");
if (ccrRow) ccrRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none"; if (ccrRow) ccrRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const infAmendRow = document.getElementById("inf-amend-flag-row"); const infAmendRow = document.getElementById("inf-amend-flag-row");
if (infAmendRow) infAmendRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none"; if (infAmendRow) infAmendRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const infPoRow = document.getElementById("inf-po-flag-row");
if (infPoRow) infPoRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const revAmendRow = document.getElementById("rev-amend-flag-row"); const revAmendRow = document.getElementById("rev-amend-flag-row");
if (revAmendRow) revAmendRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none"; if (revAmendRow) revAmendRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
const revCciRow = document.getElementById("rev-cci-flag-row"); const revCciRow = document.getElementById("rev-cci-flag-row");
if (revCciRow) revCciRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none"; if (revCciRow) revCciRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
const revPoRow = document.getElementById("rev-po-flag-row");
if (revPoRow) revPoRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
syncInfAmendEnabled(); syncInfAmendEnabled();
populateCourtPickerCore("court-picker-row", "court-picker", selectedType); populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
@@ -620,10 +632,14 @@ document.addEventListener("DOMContentLoaded", () => {
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null; const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (infAmendFlag) infAmendFlag.addEventListener("change", () => scheduleProcCalc(0)); if (infAmendFlag) infAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
const infPoFlag = document.getElementById("inf-po-flag") as HTMLInputElement | null;
if (infPoFlag) infPoFlag.addEventListener("change", () => scheduleProcCalc(0));
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null; const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0)); if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null; const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0)); if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0));
const revPoFlag = document.getElementById("rev-po-flag") as HTMLInputElement | null;
if (revPoFlag) revPoFlag.addEventListener("change", () => scheduleProcCalc(0));
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null; const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0)); if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0));

View File

@@ -221,8 +221,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.court.label": "Gericht:", "deadlines.court.label": "Gericht:",
"deadlines.flag.ccr": "Mit Widerklage auf Nichtigkeit", "deadlines.flag.ccr": "Mit Widerklage auf Nichtigkeit",
"deadlines.flag.inf_amend": "Mit Antrag auf Patentänderung (R.30)", "deadlines.flag.inf_amend": "Mit Antrag auf Patentänderung (R.30)",
"deadlines.flag.inf_po": "Mit Einspruch (R.19)",
"deadlines.flag.rev_amend": "Mit Antrag auf Patentänderung (R.49.2.a)", "deadlines.flag.rev_amend": "Mit Antrag auf Patentänderung (R.49.2.a)",
"deadlines.flag.rev_cci": "Mit Verletzungswiderklage (R.49.2.b)", "deadlines.flag.rev_cci": "Mit Verletzungswiderklage (R.49.2.b)",
"deadlines.flag.rev_po": "Mit Einspruch (R.19 i.V.m. R.46)",
"deadlines.calculate": "Fristen berechnen", "deadlines.calculate": "Fristen berechnen",
"deadlines.print": "Drucken", "deadlines.print": "Drucken",
"deadlines.reset": "\u2190 Neu berechnen", "deadlines.reset": "\u2190 Neu berechnen",
@@ -2788,8 +2790,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.court.label": "Court:", "deadlines.court.label": "Court:",
"deadlines.flag.ccr": "Counterclaim for revocation filed", "deadlines.flag.ccr": "Counterclaim for revocation filed",
"deadlines.flag.inf_amend": "Application to amend the patent filed (R.30)", "deadlines.flag.inf_amend": "Application to amend the patent filed (R.30)",
"deadlines.flag.inf_po": "Preliminary objection filed (R.19)",
"deadlines.flag.rev_amend": "Application to amend the patent filed (R.49.2.a)", "deadlines.flag.rev_amend": "Application to amend the patent filed (R.49.2.a)",
"deadlines.flag.rev_cci": "Counterclaim for infringement filed (R.49.2.b)", "deadlines.flag.rev_cci": "Counterclaim for infringement filed (R.49.2.b)",
"deadlines.flag.rev_po": "Preliminary objection filed (R.19 via R.46)",
"deadlines.calculate": "Calculate Deadlines", "deadlines.calculate": "Calculate Deadlines",
"deadlines.print": "Print", "deadlines.print": "Print",
"deadlines.reset": "\u2190 Start Over", "deadlines.reset": "\u2190 Start Over",

View File

@@ -25,6 +25,35 @@ let lastResponse: DeadlineResponse | null = null;
type ProcedureView = "timeline" | "columns"; type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns"; let procedureView: ProcedureView = "columns";
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
// Verletzungsklage etc.) once the picker collapses.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
function jurisdictionFor(btn: HTMLButtonElement): string {
const group = btn.closest<HTMLElement>(".proceeding-group");
const forum = group?.dataset.forum || "";
return FORUM_LABEL[forum] || "";
}
function proceedingDisplayName(btn: HTMLButtonElement): string {
const name = btn.querySelector("strong")?.textContent || "";
const jur = jurisdictionFor(btn);
return jur ? `${jur} ${name}` : name;
}
function activeProceedingButton(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
}
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner // Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh // so rapid input changes never let a stale response overwrite a fresh
// one. // one.
@@ -46,6 +75,34 @@ function showStep(n: number) {
} }
} }
// Read the proceeding-specific flag checkboxes and assemble the
// payload the calculator expects. Mirrors fristenrechner.ts so the
// gating semantics stay identical: with_amend on upc.inf.cfi is
// nested under with_ccr (R.30 is only available with a CCR);
// upc.rev.cfi exposes with_amend + with_cci as two independent
// gates; with_po (RoP 19.1, mig 095) gates the optional preliminary
// objection rule on both inf and rev.
function readFlags(): string[] {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const infPo = document.getElementById("inf-po-flag") as HTMLInputElement | null;
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const revPo = document.getElementById("rev-po-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "upc.inf.cfi") {
if (ccr?.checked) flags.push("with_ccr");
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
if (infPo?.checked) flags.push("with_po");
}
if (selectedType === "upc.rev.cfi") {
if (revAmend?.checked) flags.push("with_amend");
if (revCci?.checked) flags.push("with_cci");
if (revPo?.checked) flags.push("with_po");
}
return flags;
}
async function doCalc() { async function doCalc() {
const seq = ++calcSeq; const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null; const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
@@ -61,6 +118,7 @@ async function doCalc() {
const data = await calculateDeadlines({ const data = await calculateDeadlines({
proceedingType: selectedType, proceedingType: selectedType,
triggerDate, triggerDate,
flags: readFlags(),
courtId, courtId,
}); });
if (seq !== calcSeq) return; if (seq !== calcSeq) return;
@@ -70,13 +128,42 @@ async function doCalc() {
showStep(3); showStep(3);
} }
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
return data.proceedingName || "";
}
function syncTriggerEventLabel() {
const triggerEventEl = document.getElementById("trigger-event");
if (!triggerEventEl) return;
if (lastResponse) {
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
} else {
triggerEventEl.textContent = "—";
}
}
function renderResults(data: DeadlineResponse) { function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container"); const container = document.getElementById("timeline-container");
if (!container) return; if (!container) return;
const printBtn = document.getElementById("fristen-print-btn"); const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle"); const toggle = document.getElementById("fristen-view-toggle");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`); // Header shows the picked proceeding with its jurisdiction prefix
// so the user can tell UPC Verletzungsverfahren apart from DE
// Verletzungsklage once the picker collapses.
const activeBtn = activeProceedingButton();
const procName = activeBtn ? proceedingDisplayName(activeBtn)
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header"> const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong> <strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span> <span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
@@ -89,6 +176,8 @@ function renderResults(data: DeadlineResponse) {
container.innerHTML = headerHtml + bodyHtml; container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block"; if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = ""; if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
} }
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) { function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -100,18 +189,49 @@ function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string)
if (summaryName && displayName) summaryName.textContent = displayName; if (summaryName && displayName) summaryName.textContent = displayName;
} }
// syncFlagRows shows/hides the proceeding-specific checkbox rows
// based on selectedType. Same disposition as fristenrechner.ts —
// the with_amend nested-under-ccr semantic is enforced via
// syncInfAmendEnabled().
function syncFlagRows() {
const show = (id: string, when: boolean) => {
const el = document.getElementById(id);
if (el) el.style.display = when ? "" : "none";
};
show("ccr-flag-row", selectedType === "upc.inf.cfi");
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
show("inf-po-flag-row", selectedType === "upc.inf.cfi");
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
show("rev-po-flag-row", selectedType === "upc.rev.cfi");
syncInfAmendEnabled();
}
// R.30 amendment-application is only available with a CCR — disable
// (and clear) the nested inf-amend checkbox while ccr is off so the
// calc payload stays coherent. Mirrors fristenrechner.ts.
function syncInfAmendEnabled() {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (!ccr || !infAmend) return;
infAmend.disabled = !ccr.checked;
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) { function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active")); document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
selectedType = btn.dataset.code || ""; selectedType = btn.dataset.code || "";
const name = btn.querySelector("strong")?.textContent || ""; // Trigger-event label fires from the calc response (root rule).
const triggerEventEl = document.getElementById("trigger-event"); // Until step 3 renders, fall back to an em-dash placeholder.
if (triggerEventEl) triggerEventEl.textContent = name; lastResponse = null;
syncTriggerEventLabel();
void populateCourtPicker("court-picker-row", "court-picker", selectedType); void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
setProceedingPickerCollapsed(true, name); setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2); showStep(2);
scheduleCalc(0); scheduleCalc(0);
@@ -169,18 +289,35 @@ document.addEventListener("DOMContentLoaded", () => {
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null; const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0)); if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
// Flag-checkbox listeners — each flip triggers a fresh calc so the
// timeline re-projects with the new gating. ccr-flag additionally
// enables/disables the nested inf-amend row.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
syncInfAmendEnabled();
scheduleCalc(0);
});
(["inf-amend-flag", "inf-po-flag", "rev-amend-flag", "rev-cci-flag", "rev-po-flag"]).forEach((id) => {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print()); document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
initViewToggle(); initViewToggle();
onLangChange(() => { onLangChange(() => {
if (lastResponse) renderResults(lastResponse); // Active-button name updates with language change (the data-i18n
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active"); // pass swaps the inner <strong>'s text). Re-collapse the summary
// chip and re-derive the trigger event label from the lang-current
// calc response.
const activeBtn = activeProceedingButton();
if (activeBtn) { if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || ""; const summary = document.getElementById("proceeding-summary-name");
const triggerEventEl = document.getElementById("trigger-event"); if (summary) summary.textContent = proceedingDisplayName(activeBtn);
if (triggerEventEl) triggerEventEl.textContent = name;
} }
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
}); });
// Pre-select the first proceeding tile so users see a timeline // Pre-select the first proceeding tile so users see a timeline

View File

@@ -38,6 +38,14 @@ export interface CalculatedDeadline {
priority: "mandatory" | "recommended" | "optional" | "informational"; priority: "mandatory" | "recommended" | "optional" | "informational";
ruleRef: string; ruleRef: string;
legalSource?: 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; notes?: string;
notesEN?: string; notesEN?: string;
dueDate: string; dueDate: string;
@@ -240,9 +248,20 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>` ? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: ""; : "";
const ruleRef = dl.ruleRef // Prefer the structured legalSource (pretty display + youpc.org link
? `<span class="timeline-rule">${dl.ruleRef}</span>` // 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 noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText const notes = noteText

View File

@@ -494,6 +494,12 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span> <span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label> </label>
</div> </div>
<div className="date-field-row" id="inf-po-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-po-flag" />
<span data-i18n="deadlines.flag.inf_po">Mit Einspruch (R.19)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none"> <div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label"> <label className="date-label">
<input type="checkbox" id="rev-amend-flag" /> <input type="checkbox" id="rev-amend-flag" />
@@ -506,6 +512,12 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span> <span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label> </label>
</div> </div>
<div className="date-field-row" id="rev-po-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-po-flag" />
<span data-i18n="deadlines.flag.rev_po">Mit Einspruch (R.19 i.V.m. R.46)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate"> <button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen Fristen berechnen
</button> </button>

View File

@@ -1026,8 +1026,10 @@ export type I18nKey =
| "deadlines.filter.today" | "deadlines.filter.today"
| "deadlines.flag.ccr" | "deadlines.flag.ccr"
| "deadlines.flag.inf_amend" | "deadlines.flag.inf_amend"
| "deadlines.flag.inf_po"
| "deadlines.flag.rev_amend" | "deadlines.flag.rev_amend"
| "deadlines.flag.rev_cci" | "deadlines.flag.rev_cci"
| "deadlines.flag.rev_po"
| "deadlines.form.approval_hint" | "deadlines.form.approval_hint"
| "deadlines.heading" | "deadlines.heading"
| "deadlines.inbox.all" | "deadlines.inbox.all"

View File

@@ -156,6 +156,47 @@ export function renderVerfahrensablauf(): string {
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label> <label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select> <select id="court-picker" className="date-input"></select>
</div> </div>
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
so an abstract-browse user can model the same variants
(CCR, Patentänderung, Verletzungswiderklage,
Vorab-Einrede). Show/hide driven by selectedType in
the client. */}
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="inf-po-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-po-flag" />
<span data-i18n="deadlines.flag.inf_po">Mit Einspruch (R.19)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<div className="date-field-row" id="rev-po-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-po-flag" />
<span data-i18n="deadlines.flag.rev_po">Mit Einspruch (R.19 i.V.m. R.46)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate"> <button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen Fristen berechnen
</button> </button>

View File

@@ -870,6 +870,49 @@ func FormatLegalSourceDisplay(src string) string {
return b.String() return b.String()
} }
// BuildLegalSourceURL maps a structured legal_source code to a
// youpc.org/laws permalink when the cited body is hosted there. Today
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
// home yet, so the helper returns the empty string for those and the
// caller renders the display string as plain text.
//
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
// codes like UPC.RoP.220.1, UPC.UPCA.83. Sub-paragraph segments
// beyond the law-number position are dropped; youpc resolves the page
// at <type>/<number> granularity.
//
// UPC.RoP.220.1 → https://youpc.org/laws/UPCRoP/220
// UPC.RoP.29.a → https://youpc.org/laws/UPCRoP/29
// UPC.UPCA.83 → https://youpc.org/laws/UPCA/83
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
func BuildLegalSourceURL(src string) string {
src = strings.TrimSpace(src)
if src == "" {
return ""
}
parts := strings.Split(src, ".")
if len(parts) < 3 {
return ""
}
var lawType string
switch parts[0] + "." + parts[1] {
case "UPC.RoP":
lawType = "UPCRoP"
case "UPC.UPCA":
lawType = "UPCA"
case "UPC.UPCS":
lawType = "UPCS"
default:
return ""
}
number := parts[2]
if number == "" {
return ""
}
return "https://youpc.org/laws/" + lawType + "/" + number
}
// RefreshSearchView re-populates the materialised view. Safe to call on // RefreshSearchView re-populates the materialised view. Safe to call on
// every server boot — it's a CONCURRENTLY refresh against a < 1k row // every server boot — it's a CONCURRENTLY refresh against a < 1k row
// view, well under 100 ms in practice. Called from cmd/server/main.go // view, well under 100 ms in practice. Called from cmd/server/main.go

View File

@@ -40,6 +40,37 @@ func TestFormatLegalSourceDisplay(t *testing.T) {
} }
} }
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
// permalink mapping. Only the UPC corpus has a youpc home today;
// DE/EPA/EU bodies fall through to the empty string and the renderer
// shows display text without a link.
func TestBuildLegalSourceURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"UPC.RoP.23.1", "https://youpc.org/laws/UPCRoP/23"},
{"UPC.RoP.139", "https://youpc.org/laws/UPCRoP/139"},
{"UPC.RoP.220.1", "https://youpc.org/laws/UPCRoP/220"},
{"UPC.RoP.29.a", "https://youpc.org/laws/UPCRoP/29"},
{"UPC.RoP.49.2.a", "https://youpc.org/laws/UPCRoP/49"},
{"UPC.UPCA.83", "https://youpc.org/laws/UPCA/83"},
{"UPC.UPCS.40.1", "https://youpc.org/laws/UPCS/40"},
{"DE.PatG.82.1", ""},
{"DE.ZPO.276.1", ""},
{"EU.EPÜ.108", ""},
{"EU.EPC-R.79.1", ""},
{"EU.RPBA.12.1.c", ""},
{"UPC.RoP", ""},
{"", ""},
}
for _, c := range cases {
got := BuildLegalSourceURL(c.in)
if got != c.want {
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNormalizeQuery covers the input-side legal-prefix stripping that // TestNormalizeQuery covers the input-side legal-prefix stripping that
// keeps "§ 82" / "Art. 108" findable against structured legal_source // keeps "§ 82" / "Art. 108" findable against structured legal_source
// values that don't carry the prefix. // values that don't carry the prefix.

View File

@@ -54,6 +54,15 @@ type UIDeadline struct {
Priority string `json:"priority"` Priority string `json:"priority"`
RuleRef string `json:"ruleRef"` RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,omitempty"` LegalSource string `json:"legalSource,omitempty"`
// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
// renders this in the deadline card meta line; falls back to
// RuleRef when LegalSource is empty.
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
// 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 `json:"legalSourceURL,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"` NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"` DueDate string `json:"dueDate"`
@@ -283,6 +292,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
} }
if r.LegalSource != nil { if r.LegalSource != nil {
d.LegalSource = *r.LegalSource d.LegalSource = *r.LegalSource
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
} }
if r.DeadlineNotes != nil { if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes d.Notes = *r.DeadlineNotes
@@ -599,6 +610,7 @@ type RuleCalculationRule struct {
RuleRef string `json:"ruleRef,omitempty"` RuleRef string `json:"ruleRef,omitempty"`
LegalSource string `json:"legalSource,omitempty"` LegalSource string `json:"legalSource,omitempty"`
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
LegalSourceURL string `json:"legalSourceURL,omitempty"`
DurationValue int `json:"durationValue"` DurationValue int `json:"durationValue"`
DurationUnit string `json:"durationUnit"` DurationUnit string `json:"durationUnit"`
Party string `json:"party,omitempty"` Party string `json:"party,omitempty"`
@@ -670,6 +682,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
if rule.LegalSource != nil { if rule.LegalSource != nil {
out.Rule.LegalSource = *rule.LegalSource out.Rule.LegalSource = *rule.LegalSource
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource) out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
} }
if rule.PrimaryParty != nil { if rule.PrimaryParty != nil {
out.Rule.Party = *rule.PrimaryParty out.Rule.Party = *rule.PrimaryParty
@@ -1217,6 +1230,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
} }
if r.LegalSource != nil { if r.LegalSource != nil {
d.LegalSource = *r.LegalSource d.LegalSource = *r.LegalSource
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
} }
if r.DeadlineNotes != nil { if r.DeadlineNotes != nil {
d.Notes = *r.DeadlineNotes d.Notes = *r.DeadlineNotes