Merge: t-paliad-136 Phase B — card-click → calc panel → add-to-project (v4 complete)

This commit is contained in:
m
2026-05-05 14:09:02 +02:00
8 changed files with 1239 additions and 12 deletions

View File

@@ -1333,30 +1333,501 @@ function renderSearchResultsInto(containerId: string, data: SearchResponse) {
<div class="fristen-search-cards">${cardsHtml}</div>`;
}
// wirePillClicks attaches the rule/trigger drill-in click handler to a
// results container. Idempotent across re-renders because the listener
// lives on the container, not on individual pill anchors.
// wirePillClicks attaches the v4 card-click → inline calc-panel handler
// to a results container. Idempotent across re-renders because the
// listener lives on the container, not on individual pill anchors.
//
// v4 (t-paliad-136 Phase B): the primary click action on ANY card —
// header, pill, or body — expands the card with an inline calc panel
// (trigger-date input + flag checkboxes + computed deadline + add-to-
// project CTA). Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve
// the legacy drill-to-Pathway-A semantics via the `<a href="…">`
// fallback (browser default new-tab behaviour). Trigger pills still
// drill to the youpc-style trigger-event picker on plain click — those
// concepts (Wiedereinsetzung, Weiterbehandlung) don't have a single
// rule to compute against, so the inline calc panel doesn't apply.
function wirePillClicks(container: HTMLElement) {
if (container.dataset.pillClicksWired === "1") return;
container.dataset.pillClicksWired = "1";
container.addEventListener("click", (e) => {
// Clicks inside an open calc panel are handled by their own
// listeners — do not re-trigger the expand/collapse logic.
if ((e.target as HTMLElement).closest(".fristen-card-calc")) return;
const pill = (e.target as HTMLElement).closest<HTMLAnchorElement>(".fristen-pill");
if (!pill) return;
const card = (e.target as HTMLElement).closest<HTMLElement>(".fristen-card");
if (!card) return;
const me = e as MouseEvent;
// Modifier-key fallback: let `<a href>` open in a new tab / new
// window for users who want to deep-link into Pathway A. Don't
// interfere with normal text selection (no expand on shift-drag).
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
e.preventDefault();
const kind = pill.dataset.kind;
if (kind === "rule") {
const proc = pill.dataset.proc;
if (!proc) return;
drillToProceeding(proc, pill.dataset.focus || null);
} else if (kind === "trigger") {
// Trigger pills drill to the trigger-event picker (legacy youpc
// pathway). The inline calc panel only applies to rule pills.
if (pill && pill.dataset.kind === "trigger") {
e.preventDefault();
const id = Number(pill.dataset.triggerId);
if (Number.isFinite(id)) drillToTrigger(id);
return;
}
e.preventDefault();
expandCardCalc(card, pill);
});
}
// ============================================================================
// v4 card-click → inline calc panel (t-paliad-136 Phase B)
// ============================================================================
// Click a result card → expand inline → user enters trigger date + flags →
// server computes one deadline → user can add it to a project.
//
// Only one card may be expanded at a time (multiple panels would confuse
// "which trigger date am I looking at?"). Collapsing happens automatically
// when another card is clicked, when × is pressed, or when the user clicks
// outside the panel.
interface RuleCalcResponse {
rule: {
id: string;
localCode?: string;
nameDE: string;
nameEN: string;
ruleRef?: string;
legalSource?: string;
legalSourceDisplay?: string;
durationValue: number;
durationUnit: string;
party?: string;
isMandatory: boolean;
notesDE?: string;
notesEN?: string;
};
proceeding: { code: string; nameDE: string; nameEN: string };
triggerDate: string;
originalDate: string;
dueDate: string;
wasAdjusted: boolean;
adjustmentReason?: {
holidays?: Array<{ name: string; date: string }>;
upcVacation?: boolean;
moveToNextWorkday?: boolean;
};
isCourtSet: boolean;
flagsApplied?: string[];
flagsRequired?: string[];
}
let lastCalcByCard: WeakMap<HTMLElement, RuleCalcResponse> = new WeakMap();
let calcDebounce: number | undefined;
let calcSeq = 0;
function collapseAnyExpandedCard() {
document.querySelectorAll<HTMLElement>(".fristen-card.is-expanded").forEach((c) => {
c.classList.remove("is-expanded");
c.setAttribute("aria-expanded", "false");
const panel = c.querySelector<HTMLElement>(".fristen-card-calc");
if (panel) panel.remove();
});
}
function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
// Click a different card → collapse the current one first.
if (!card.classList.contains("is-expanded")) {
collapseAnyExpandedCard();
} else {
// Already expanded; if the user clicked a different pill, switch
// selection. If they clicked the body again, do nothing.
if (autoSelectPill) selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
return;
}
const payload = card.dataset.cardPayload;
if (!payload) return;
let cardData: SearchCard;
try {
cardData = JSON.parse(payload) as SearchCard;
} catch {
return;
}
// Only rule pills are computable. Drop trigger pills from the picker.
const rulePills = cardData.pills.filter((p) => p.kind === "rule");
if (rulePills.length === 0) return;
card.classList.add("is-expanded");
card.setAttribute("aria-expanded", "true");
const panel = buildCalcPanel(cardData, rulePills);
card.appendChild(panel);
// Auto-select the clicked pill if it's a rule pill; otherwise the
// first pill is preselected by buildCalcPanel.
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
}
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
const panel = document.createElement("div");
panel.className = "fristen-card-calc";
// stopPropagation so clicks inside the panel don't bubble to the
// card-level expand handler.
panel.addEventListener("click", (e) => e.stopPropagation());
panel.addEventListener("keydown", (e) => e.stopPropagation());
const lang = getLang();
const today = new Date().toISOString().split("T")[0];
// Pill picker (only when >1 rule pill).
const pickerHtml = rulePills.length <= 1
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
const src = p.legal_source_display || p.legal_source || "";
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${i === 0 ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
</label>`;
}).join("")}
</fieldset>`;
panel.innerHTML = `
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
${pickerHtml}
<div class="fristen-card-calc-inputs">
<label class="fristen-card-calc-trigger">
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.trigger.label"))}</span>
<input type="date" class="fristen-card-calc-trigger-input" value="${escAttr(today)}" />
</label>
<div class="fristen-card-calc-flags" hidden></div>
</div>
<div class="fristen-card-calc-result" aria-live="polite">
<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>
</div>
<div class="fristen-card-calc-actions">
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add" disabled>${escHtml(t("deadlines.card.calc.add_to_project"))}</button>
</div>
<div class="fristen-card-calc-msg" aria-live="polite"></div>
`;
// Wire interactions.
const close = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-close")!;
close.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) {
card.classList.remove("is-expanded");
card.setAttribute("aria-expanded", "false");
panel.remove();
}
});
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
dateInput.addEventListener("input", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) scheduleCardCalc(card);
});
panel.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) scheduleCardCalc(card, 0);
});
});
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (!card) return;
const last = lastCalcByCard.get(card);
if (!last) return;
void addCalcToProject(card, last);
});
return panel;
}
function selectCalcPill(card: HTMLElement, proc?: string | null, focus?: string | null) {
if (!proc) return;
const radios = card.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]');
radios.forEach((r) => {
if (r.dataset.proc === proc && (!focus || r.dataset.focus === focus)) {
r.checked = true;
r.dispatchEvent(new Event("change", { bubbles: true }));
}
});
}
function scheduleCardCalc(card: HTMLElement, delayMs = 200) {
if (calcDebounce !== undefined) clearTimeout(calcDebounce);
calcDebounce = window.setTimeout(() => void runCardCalc(card), delayMs);
}
async function runCardCalc(card: HTMLElement) {
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
if (!panel) return;
const result = panel.querySelector<HTMLElement>(".fristen-card-calc-result")!;
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
const triggerDate = dateInput.value;
if (!triggerDate) return;
// Resolve currently-selected pill (proc + ruleLocalCode).
let proc = "";
let focus = "";
const checked = panel.querySelector<HTMLInputElement>('input[name="fristen-card-calc-pill"]:checked');
if (checked) {
proc = checked.dataset.proc || "";
focus = checked.dataset.focus || "";
} else {
const hidden = panel.querySelector<HTMLInputElement>(".fristen-card-calc-pill-picker");
if (hidden) {
proc = hidden.dataset.proc || "";
focus = hidden.dataset.focus || "";
}
}
if (!proc || !focus) return;
// Read flag checkboxes.
const flags: string[] = [];
panel.querySelectorAll<HTMLInputElement>('.fristen-card-calc-flags input[type="checkbox"]:checked').forEach((cb) => {
if (cb.value) flags.push(cb.value);
});
result.innerHTML = `<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>`;
msgEl.textContent = "";
addBtn.disabled = true;
const seq = ++calcSeq;
let resp: Response;
try {
resp = await fetch("/api/tools/fristenrechner/calculate-rule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingCode: proc,
ruleLocalCode: focus,
triggerDate,
flags,
}),
});
} catch {
if (seq !== calcSeq) return;
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(t("deadlines.card.calc.result.error"))}</div>`;
return;
}
if (seq !== calcSeq) return;
if (!resp.ok) {
const data = (await resp.json().catch(() => ({} as { error?: string }))) as { error?: string };
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(data.error || t("deadlines.card.calc.result.error"))}</div>`;
return;
}
const calc = (await resp.json()) as RuleCalcResponse;
if (seq !== calcSeq) return;
lastCalcByCard.set(card, calc);
renderCalcResult(card, calc);
syncFlagCheckboxes(card, calc);
addBtn.disabled = calc.isCourtSet || !calc.dueDate;
if (calc.isCourtSet) {
addBtn.textContent = t("deadlines.card.calc.add_to_project.disabled");
} else {
addBtn.textContent = t("deadlines.card.calc.add_to_project");
}
}
function syncFlagCheckboxes(card: HTMLElement, calc: RuleCalcResponse) {
const flagsEl = card.querySelector<HTMLElement>(".fristen-card-calc-flags");
if (!flagsEl) return;
const required = calc.flagsRequired || [];
if (required.length === 0) {
flagsEl.hidden = true;
flagsEl.innerHTML = "";
return;
}
flagsEl.hidden = false;
// Preserve current state when re-rendering: check current DOM for
// existing checkbox values so user input survives a recalc.
const existing = new Map<string, boolean>();
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
existing.set(cb.value, cb.checked);
});
const labelKey = (flag: string) => {
const k = `deadlines.card.calc.flag.${flag}`;
const localised = tDyn(k);
return localised === k ? flag : localised;
};
flagsEl.innerHTML = `
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.flags.label"))}</span>
${required.map((f) => {
const checked = existing.get(f) ?? (calc.flagsApplied || []).includes(f);
return `<label class="fristen-card-calc-flag">
<input type="checkbox" value="${escAttr(f)}" ${checked ? "checked" : ""} />
<span>${escHtml(labelKey(f))}</span>
</label>`;
}).join("")}
`;
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
cb.addEventListener("change", () => scheduleCardCalc(card, 0));
});
}
function renderCalcResult(card: HTMLElement, calc: RuleCalcResponse) {
const result = card.querySelector<HTMLElement>(".fristen-card-calc-result");
if (!result) return;
if (calc.isCourtSet) {
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-court">${escHtml(t("deadlines.card.calc.result.court_set"))}</div>`;
return;
}
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const durLabel = `${calc.rule.durationValue} ${formatDurationUnit(calc.rule.durationUnit, lang)}`;
const dueLabel = formatDate(calc.dueDate);
const fromLabel = formatDate(calc.triggerDate);
const adjustmentChip = calc.wasAdjusted
? renderAdjustmentChip(calc, lang)
: "";
result.innerHTML = `
<div class="fristen-card-calc-result-row">
<span class="fristen-card-calc-result-arrow" aria-hidden="true">►</span>
<span class="fristen-card-calc-result-due"><strong>${escHtml(dueLabel)}</strong></span>
<span class="fristen-card-calc-result-detail">(${escHtml(durLabel)} ${escHtml(t("deadlines.card.calc.result.from_trigger"))} ${escHtml(fromLabel)})</span>
</div>
${adjustmentChip}
<div class="fristen-card-calc-result-rule">${escHtml(ruleName)}</div>
`;
}
function renderAdjustmentChip(calc: RuleCalcResponse, _lang: "de" | "en"): string {
const reason = calc.adjustmentReason;
let why = "";
if (reason && reason.upcVacation) {
why = "UPC-Sommerferien (27.7.28.8.)";
} else if (reason && reason.holidays && reason.holidays.length > 0) {
why = reason.holidays.map((h) => h.name).join(", ");
} else {
why = "Wochenende / Feiertag";
}
return `<div class="fristen-card-calc-result-shift">
${escHtml(t("deadlines.card.calc.result.shifted_from"))} <strong>${escHtml(formatDate(calc.originalDate))}</strong>
${escHtml(t("deadlines.card.calc.result.shifted_because"))} ${escHtml(why)}.
</div>`;
}
function formatDurationUnit(unit: string, lang: "de" | "en"): string {
const map: Record<string, { de: string; en: string }> = {
days: { de: "Tage", en: "days" },
working_days: { de: "Arbeitstage", en: "working days" },
weeks: { de: "Wochen", en: "weeks" },
months: { de: "Monate", en: "months" },
years: { de: "Jahre", en: "years" },
};
return map[unit] ? map[unit][lang] : unit;
}
async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
if (!panel) return;
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
msgEl.textContent = "";
addBtn.disabled = true;
const projects = await fetchProjects();
if (projects.length === 0) {
addBtn.disabled = false;
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.modal.no_akten"))}</span> <a href="/projects/new">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>`;
return;
}
// Inline picker — render a compact <select> + Confirm button under
// the result. Keeps the user inside the card; no full modal needed.
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const dueLabel = formatDate(calc.dueDate);
msgEl.innerHTML = `
<div class="fristen-card-calc-add-picker">
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
<select class="fristen-card-calc-add-select">
${projects.map((p) => {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref ? `${indent}${ref}${p.title}` : `${indent}${p.title}`;
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
}).join("")}
</select>
</label>
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add-confirm">${escHtml(t("deadlines.save.modal.submit"))}</button>
<button type="button" class="btn-cancel fristen-card-calc-add-cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
</div>
`;
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
msgEl.innerHTML = "";
addBtn.disabled = false;
});
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!.addEventListener("click", async () => {
const projectID = sel.value;
if (!projectID) return;
const dlNotes = lang === "en"
? (calc.rule.notesEN || calc.rule.notesDE)
: calc.rule.notesDE;
const payload = {
deadlines: [{
title: ruleName,
rule_code: calc.rule.ruleRef || undefined,
due_date: calc.dueDate,
original_due_date: calc.originalDate || undefined,
// m's Q2 (2026-05-05): use 'fristenrechner' (existing tag), not
// 'fristenrechner_card'. Audit-log differentiation is not needed.
source: "fristenrechner",
notes: dlNotes || undefined,
}],
};
const confirm = msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!;
confirm.disabled = true;
try {
const r = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const data = (await r.json().catch(() => ({}))) as { error?: string };
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(data.error || t("deadlines.save.error"))}</span>`;
addBtn.disabled = false;
return;
}
msgEl.innerHTML = `<span class="form-msg form-msg-ok">${escHtml(t("deadlines.save.success"))} (${escHtml(dueLabel)}) <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a></span>`;
addBtn.disabled = false;
} catch {
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.error"))}</span>`;
addBtn.disabled = false;
}
});
}
// Collapse the open card on Escape key for quick keyboard exit.
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const open = document.querySelector<HTMLElement>(".fristen-card.is-expanded");
if (open) {
open.classList.remove("is-expanded");
open.setAttribute("aria-expanded", "false");
open.querySelector<HTMLElement>(".fristen-card-calc")?.remove();
}
});
function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
const name = lang === "en" ? card.concept.name_en : card.concept.name_de;
const altName = lang === "en" ? card.concept.name_de : card.concept.name_en;
@@ -1380,8 +1851,13 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
// v4 (t-paliad-136 Phase B): stash the card payload on the article so
// expandCardCalc() can read pills + concept name without re-querying.
// JSON.stringify → escAttr survives htmlentity round-trip.
const cardPayload = escAttr(JSON.stringify(card));
return `
<article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}">
<article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}" data-card-payload="${cardPayload}" tabindex="0" role="button" aria-expanded="false" title="${escAttr(t("deadlines.card.calc.expand_hint"))}">
<header class="fristen-card-header">
<h3 class="fristen-card-title">${escHtml(name)}</h3>
<span class="fristen-card-altname">${escHtml(altName)}</span>

View File

@@ -284,6 +284,25 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.search.results.count": "{n} Treffer",
"deadlines.search.results.count_one": "1 Treffer",
"deadlines.search.clear": "Suche leeren",
// Fristenrechner — card-click → calc panel → add-to-project (t-paliad-136 Phase B)
"deadlines.card.calc.expand_hint": "Frist berechnen oder zu Akte hinzufügen",
"deadlines.card.calc.close": "schließen",
"deadlines.card.calc.pill_picker.label": "Welcher Kontext?",
"deadlines.card.calc.trigger.label": "Datum des auslösenden Ereignisses",
"deadlines.card.calc.flags.label": "Bedingungen:",
"deadlines.card.calc.flag.with_ccr": "Mit Nichtigkeitswiderklage",
"deadlines.card.calc.flag.with_amend": "Mit Antrag auf Patentänderung",
"deadlines.card.calc.flag.with_cci": "Mit Verletzungswiderklage",
"deadlines.card.calc.result.due": "Frist:",
"deadlines.card.calc.result.original_from": "ab",
"deadlines.card.calc.result.from_trigger": "ab",
"deadlines.card.calc.result.shifted_from": "Verschoben vom",
"deadlines.card.calc.result.shifted_because": "wegen",
"deadlines.card.calc.result.court_set": "Gericht-bestimmt — kein berechenbares Datum.",
"deadlines.card.calc.result.calculating": "Berechne…",
"deadlines.card.calc.result.error": "Berechnung fehlgeschlagen.",
"deadlines.card.calc.add_to_project": "Zu Akte hinzufügen",
"deadlines.card.calc.add_to_project.disabled": "Gerichtsbestimmt — manuell anlegen",
"deadlines.pathway.fork.heading": "Was möchten Sie tun?",
"deadlines.pathway.a.title": "Verfahrensablauf informieren",
"deadlines.pathway.a.desc": "Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.",
@@ -1867,6 +1886,25 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.search.results.count": "{n} hits",
"deadlines.search.results.count_one": "1 hit",
"deadlines.search.clear": "Clear search",
// Fristenrechner — card-click → calc panel → add-to-project (t-paliad-136 Phase B)
"deadlines.card.calc.expand_hint": "Calculate deadline or add to project",
"deadlines.card.calc.close": "close",
"deadlines.card.calc.pill_picker.label": "Which context?",
"deadlines.card.calc.trigger.label": "Date of triggering event",
"deadlines.card.calc.flags.label": "Conditions:",
"deadlines.card.calc.flag.with_ccr": "With counterclaim for revocation",
"deadlines.card.calc.flag.with_amend": "With application to amend",
"deadlines.card.calc.flag.with_cci": "With counterclaim for infringement",
"deadlines.card.calc.result.due": "Deadline:",
"deadlines.card.calc.result.original_from": "from",
"deadlines.card.calc.result.from_trigger": "from",
"deadlines.card.calc.result.shifted_from": "Shifted from",
"deadlines.card.calc.result.shifted_because": "due to",
"deadlines.card.calc.result.court_set": "Court-determined — no calculable date.",
"deadlines.card.calc.result.calculating": "Calculating…",
"deadlines.card.calc.result.error": "Calculation failed.",
"deadlines.card.calc.add_to_project": "Add to project",
"deadlines.card.calc.add_to_project.disabled": "Court-determined — add manually",
"deadlines.pathway.fork.heading": "What would you like to do?",
"deadlines.pathway.a.title": "Browse a proceeding",
"deadlines.pathway.a.desc": "Pick a proceeding type and see all its deadlines on a single timeline.",

View File

@@ -540,6 +540,24 @@ export type I18nKey =
| "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday"
| "deadlines.calculate"
| "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled"
| "deadlines.card.calc.close"
| "deadlines.card.calc.expand_hint"
| "deadlines.card.calc.flag.with_amend"
| "deadlines.card.calc.flag.with_cci"
| "deadlines.card.calc.flag.with_ccr"
| "deadlines.card.calc.flags.label"
| "deadlines.card.calc.pill_picker.label"
| "deadlines.card.calc.result.calculating"
| "deadlines.card.calc.result.court_set"
| "deadlines.card.calc.result.due"
| "deadlines.card.calc.result.error"
| "deadlines.card.calc.result.from_trigger"
| "deadlines.card.calc.result.original_from"
| "deadlines.card.calc.result.shifted_because"
| "deadlines.card.calc.result.shifted_from"
| "deadlines.card.calc.trigger.label"
| "deadlines.col.akte"
| "deadlines.col.both"
| "deadlines.col.court"

View File

@@ -2160,6 +2160,257 @@ input[type="range"]::-moz-range-thumb {
.fristen-pill-party { grid-column: 2; }
}
/* v4 (t-paliad-136 Phase B): card-click → inline calc panel.
The card is now interactive — click anywhere in the body (or on a
pill) to expand the panel that holds the trigger-date input, flag
checkboxes, computed deadline, and "Add to project" CTA. Only one
card may be expanded at a time; opening another collapses the
previous. */
.fristen-card[role="button"] {
cursor: pointer;
transition: border-color 120ms, box-shadow 120ms;
}
.fristen-card[role="button"]:hover:not(.is-expanded) {
border-color: var(--brand-lime, #c6f41c);
box-shadow: 0 0 0 2px rgba(198, 244, 28, 0.18);
}
.fristen-card.is-expanded {
border-color: var(--brand-lime, #c6f41c);
box-shadow: 0 0 0 2px rgba(198, 244, 28, 0.25);
}
.fristen-card-calc {
margin-top: 1rem;
padding: 0.9rem 1rem 0.8rem;
border-top: 1px dashed var(--color-border, #e2e2e2);
background: rgba(198, 244, 28, 0.04);
border-radius: 0 0 8px 8px;
display: flex;
flex-direction: column;
gap: 0.7rem;
position: relative;
cursor: default;
}
.fristen-card-calc-close {
position: absolute;
top: 0.4rem;
right: 0.5rem;
background: transparent;
border: 0;
font-size: 1.4rem;
line-height: 1;
color: var(--color-muted, #888);
cursor: pointer;
padding: 0.1rem 0.4rem;
}
.fristen-card-calc-close:hover {
color: var(--color-text, #222);
}
.fristen-card-calc-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-muted, #777);
font-weight: 600;
display: block;
margin-bottom: 0.3rem;
}
.fristen-card-calc-pill-picker {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.fristen-card-calc-pill-picker legend {
padding: 0;
margin-bottom: 0.3rem;
}
.fristen-card-calc-pill-option {
display: grid;
grid-template-columns: auto minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 1fr);
gap: 0.5rem;
align-items: center;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 5px;
background: var(--color-bg, #fff);
cursor: pointer;
font-size: 0.88rem;
}
.fristen-card-calc-pill-option:has(input:checked) {
border-color: var(--brand-lime, #c6f41c);
background: rgba(198, 244, 28, 0.08);
}
.fristen-card-calc-pill-option-proc {
font-weight: 600;
color: var(--color-text, #222);
}
.fristen-card-calc-pill-option-rule {
color: var(--color-text, #222);
}
.fristen-card-calc-pill-option-source {
font-size: 0.8rem;
color: var(--color-muted, #888);
font-family: ui-monospace, monospace;
}
.fristen-card-calc-inputs {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.fristen-card-calc-trigger {
display: flex;
flex-direction: column;
min-width: 200px;
}
.fristen-card-calc-trigger-input {
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 5px;
font-size: 0.95rem;
background: var(--color-bg, #fff);
}
.fristen-card-calc-flags {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.fristen-card-calc-flag {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.88rem;
padding: 0.3rem 0.55rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 4px;
cursor: pointer;
}
.fristen-card-calc-flag:has(input:checked) {
border-color: var(--brand-lime, #c6f41c);
background: rgba(198, 244, 28, 0.08);
}
.fristen-card-calc-result {
padding: 0.6rem 0.8rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 6px;
min-height: 2.6rem;
}
.fristen-card-calc-result-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.fristen-card-calc-result-arrow {
color: var(--brand-lime-strong, #88a800);
font-weight: 700;
}
.fristen-card-calc-result-due strong {
font-size: 1.1rem;
color: var(--color-text, #222);
}
.fristen-card-calc-result-detail {
color: var(--color-muted, #777);
font-size: 0.88rem;
}
.fristen-card-calc-result-rule {
color: var(--color-muted, #888);
font-size: 0.83rem;
margin-top: 0.3rem;
font-style: italic;
}
.fristen-card-calc-result-shift {
margin-top: 0.4rem;
padding: 0.4rem 0.55rem;
background: rgba(255, 184, 0, 0.1);
border-left: 3px solid #f5a800;
border-radius: 3px;
font-size: 0.85rem;
color: #6c4a00;
}
.fristen-card-calc-result-status {
color: var(--color-muted, #888);
font-size: 0.9rem;
font-style: italic;
}
.fristen-card-calc-result-error {
color: var(--color-danger, #c44);
font-style: normal;
}
.fristen-card-calc-result-court {
color: #8a5d00;
font-style: normal;
}
.fristen-card-calc-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fristen-card-calc-add[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.fristen-card-calc-msg {
font-size: 0.88rem;
min-height: 0;
}
.fristen-card-calc-msg .form-msg {
display: inline-block;
padding: 0.3rem 0.55rem;
border-radius: 4px;
}
.fristen-card-calc-msg .form-msg-ok {
background: rgba(140, 200, 80, 0.15);
color: #2e6a16;
}
.fristen-card-calc-msg .form-msg-error {
background: rgba(200, 60, 60, 0.1);
color: #a31919;
}
.fristen-card-calc-add-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
padding: 0.5rem;
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 5px;
background: var(--color-bg, #fff);
}
.fristen-card-calc-add-select {
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 4px;
min-width: 240px;
font-size: 0.95rem;
}
@media (max-width: 720px) {
.fristen-card-calc-pill-option {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}
.fristen-card-calc-pill-option-rule,
.fristen-card-calc-pill-option-source { grid-column: 2; }
}
/* Drill-in highlight — applied to the focused timeline / column row for
~2.4 s after a pill click pre-selects the proceeding and computes the
timeline. Fades the row to draw attention without staying loud. */

View File

@@ -57,6 +57,67 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// POST /api/tools/fristenrechner/calculate-rule — single-rule calc for
// the v4 (t-paliad-136 Phase B) result-card click flow.
//
// Body: { ruleId? } OR { proceedingCode, ruleLocalCode }, plus
// triggerDate (YYYY-MM-DD, required) and flags? (string array,
// optional condition_flag inputs).
//
// Returns a RuleCalculation (see services.RuleCalculation) — the rule
// metadata + computed dueDate / originalDate / adjustmentReason. Used by
// the result-card calc panel; distinct from the full-timeline endpoint
// at POST /api/tools/fristenrechner.
func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
var req struct {
RuleID string `json:"ruleId"`
ProceedingCode string `json:"proceedingCode"`
RuleLocalCode string `json:"ruleLocalCode"`
TriggerDate string `json:"triggerDate"`
Flags []string `json:"flags,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
return
}
if req.TriggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerDate ist erforderlich"})
return
}
if req.RuleID == "" && (req.ProceedingCode == "" || req.RuleLocalCode == "") {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "Entweder ruleId oder (proceedingCode + ruleLocalCode) ist erforderlich",
})
return
}
resp, err := dbSvc.fristenrechner.CalculateRule(r.Context(), services.CalcRuleParams{
RuleID: req.RuleID,
ProceedingCode: req.ProceedingCode,
RuleLocalCode: req.RuleLocalCode,
TriggerDate: req.TriggerDate,
Flags: req.Flags,
})
if err != nil {
switch {
case errors.Is(err, services.ErrUnknownRule):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannte Regel"})
case errors.Is(err, services.ErrUnknownProceedingType):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingCode})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return
}
writeJSON(w, http.StatusOK, resp)
}
// GET /api/tools/proceeding-types — metadata list for the wizard buttons.
// Returns 503 with an empty array when DATABASE_URL is unset so the page
// still renders (buttons are server-rendered from tsx and don't depend on

View File

@@ -134,6 +134,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)

View File

@@ -416,6 +416,258 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
}, nil
}
// ErrUnknownRule is returned when CalculateRule can't resolve the
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
var ErrUnknownRule = errors.New("unknown rule")
// CalcRuleParams identifies a single rule and the inputs needed to
// compute one deadline from it. Caller supplies either RuleID OR the
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
// hand from the concept-card pill it just received a click on.
type CalcRuleParams struct {
RuleID string // optional — UUID
ProceedingCode string // optional — used with RuleLocalCode
RuleLocalCode string // optional — paliad.deadline_rules.code
TriggerDate string // required — YYYY-MM-DD
Flags []string // optional — condition_flag inputs
}
// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc
// response that backs the result-card click → calc-panel flow. Distinct
// from UIDeadline (which represents one rendered timeline row inside a
// full-proceeding response): RuleCalculation is self-contained — caller
// gets the rule metadata + the computed date in one payload, no separate
// proceeding-types lookup needed.
//
// Trigger semantics: TriggerDate is the immediate parent event's
// effective date — i.e. when the user clicks "Duplik" in the card and
// types "2026-05-05", they mean "I received the Replik on 2026-05-05".
// We do NOT walk the parent chain; callers wanting the full timeline
// for a proceeding still go through Calculate.
type RuleCalculation struct {
Rule RuleCalculationRule `json:"rule"`
Proceeding RuleCalculationProceeding `json:"proceeding"`
TriggerDate string `json:"triggerDate"`
OriginalDate string `json:"originalDate"`
DueDate string `json:"dueDate"`
WasAdjusted bool `json:"wasAdjusted"`
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsCourtSet bool `json:"isCourtSet"`
// FlagsApplied lists the condition_flag values from the rule that
// the caller's Flags satisfied. Empty when the rule has no
// condition_flag, OR when the caller didn't satisfy the gate. Lets
// the frontend show "Mit Nichtigkeitswiderklage angewandt" hints.
FlagsApplied []string `json:"flagsApplied,omitempty"`
// FlagsRequired is the rule's condition_flag in canonical order so
// the frontend can render checkboxes for each flag the rule gates on.
FlagsRequired []string `json:"flagsRequired,omitempty"`
}
// RuleCalculationRule mirrors the small subset of DeadlineRule the
// frontend needs to render the calc panel.
type RuleCalculationRule struct {
ID string `json:"id"`
LocalCode string `json:"localCode,omitempty"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
RuleRef string `json:"ruleRef,omitempty"`
LegalSource string `json:"legalSource,omitempty"`
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
DurationValue int `json:"durationValue"`
DurationUnit string `json:"durationUnit"`
Party string `json:"party,omitempty"`
IsMandatory bool `json:"isMandatory"`
NotesDE string `json:"notesDE,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
}
// RuleCalculationProceeding identifies the proceeding context for the
// rule. Used by the frontend for display + by the add-to-project flow.
type RuleCalculationProceeding struct {
Code string `json:"code"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
}
// CalculateRule computes a single deadline from a rule + trigger date.
// Used by the v4 result-card click flow. Distinct from Calculate: no
// parent-chain walk, no full-timeline rendering — just one date out.
//
// When the rule is court-determined (primary_party='court' or event_type
// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true;
// the caller should disable the "Add to project" CTA in that case.
//
// When the rule has condition_flag and the caller's Flags satisfy every
// element AND alt_duration_value is non-NULL, the calc swaps to alt_*
// (matches the existing flag-conditional semantics in Calculate).
//
// When the rule has condition_flag and the caller's Flags do NOT satisfy
// every element, the calc still proceeds with the base duration_value
// and surfaces FlagsRequired so the frontend can render the gating
// checkboxes. The result IS the date the rule would be due if the user
// confirmed the flag — letting the user toggle the checkbox and see the
// duration change live.
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) {
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
if err != nil {
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
}
rule, pt, err := s.resolveRule(ctx, params)
if err != nil {
return nil, err
}
out := &RuleCalculation{
Rule: RuleCalculationRule{
ID: rule.ID.String(),
NameDE: rule.Name,
NameEN: rule.NameEN,
DurationValue: rule.DurationValue,
DurationUnit: rule.DurationUnit,
IsMandatory: rule.IsMandatory,
},
Proceeding: RuleCalculationProceeding{
Code: pt.Code,
NameDE: pt.Name,
NameEN: pt.NameEN,
},
TriggerDate: params.TriggerDate,
}
if rule.Code != nil {
out.Rule.LocalCode = *rule.Code
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
}
if rule.LegalSource != nil {
out.Rule.LegalSource = *rule.LegalSource
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
}
if rule.PrimaryParty != nil {
out.Rule.Party = *rule.PrimaryParty
}
if rule.DeadlineNotes != nil {
out.Rule.NotesDE = *rule.DeadlineNotes
}
if rule.DeadlineNotesEn != nil {
out.Rule.NotesEN = *rule.DeadlineNotesEn
}
if len(rule.ConditionFlag) > 0 {
out.FlagsRequired = []string(rule.ConditionFlag)
}
// Court-determined: no calculable date.
if isCourtDeterminedRule(*rule) {
out.IsCourtSet = true
return out, nil
}
// Resolve flag-conditional duration. Same semantics as Calculate
// (services/fristenrechner.go:368): all flags satisfied + alt
// values present → swap; otherwise use base values.
flagSet := make(map[string]struct{}, len(params.Flags))
for _, f := range params.Flags {
flagSet[f] = struct{}{}
}
durationValue := rule.DurationValue
durationUnit := rule.DurationUnit
if len(rule.ConditionFlag) > 0 && allFlagsSet(rule.ConditionFlag, flagSet) {
out.FlagsApplied = []string(rule.ConditionFlag)
if rule.AltDurationValue != nil {
durationValue = *rule.AltDurationValue
}
if rule.AltDurationUnit != nil {
durationUnit = *rule.AltDurationUnit
}
if rule.AltRuleCode != nil {
out.Rule.RuleRef = *rule.AltRuleCode
}
}
// Zero-duration non-court-determined rules are "filed at the same
// time as parent" markers (UPC_REV.app_to_amend, UPC_REV.cc_inf):
// effectively mean "due on the trigger date itself". The card-click
// flow doesn't need to surface those as a calc panel — but if it
// does, returning the trigger date is the right answer.
if durationValue == 0 {
out.OriginalDate = params.TriggerDate
out.DueDate = params.TriggerDate
return out, nil
}
endDate := addDuration(triggerDate, durationValue, durationUnit)
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
out.OriginalDate = endDate.Format("2006-01-02")
out.DueDate = adjusted.Format("2006-01-02")
out.WasAdjusted = wasAdj
out.AdjustmentReason = reason
return out, nil
}
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
// frontend uses the latter form (it has the pill context) and the
// programmatic / test caller can use the former.
func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) {
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
}
const ptCols = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`
var rule models.DeadlineRule
var pt models.ProceedingType
if params.RuleID != "" {
err := s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE id = $1 AND is_active = true`, params.RuleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
}
if err != nil {
return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err)
}
if rule.ProceedingTypeID == nil {
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT `+ptCols+`
FROM paliad.proceeding_types
WHERE id = $1`, *rule.ProceedingTypeID)
if err != nil {
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
}
return &rule, &pt, nil
}
err := s.rules.db.GetContext(ctx, &pt,
`SELECT `+ptCols+`
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, params.ProceedingCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownProceedingType
}
if err != nil {
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err)
}
err = s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
pt.ID, params.RuleLocalCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
}
if err != nil {
return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err)
}
return &rule, &pt, nil
}
// ListFristenrechnerTypes returns the proceeding types that populate the
// Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order.
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) {

View File

@@ -1,8 +1,14 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -102,3 +108,127 @@ func TestAllFlagsSet(t *testing.T) {
})
}
}
// TestCalculateRule covers the v4 (t-paliad-136 Phase B) single-rule
// calculator that backs POST /api/tools/fristenrechner/calculate-rule.
// Distinct from Calculate: no parent-chain walk; the trigger date is
// the immediate parent event's effective date, and the calc applies
// the rule's duration + holiday adjustment directly.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring TestDeadlineSearch.
func TestCalculateRule(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
svc := NewFristenrechnerService(rules, holidays)
t.Run("plain rule calc — UPC_INF inf.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("inf.sod is not court-set; got IsCourtSet=true")
}
if got.DueDate != "2026-04-15" {
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
}
if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
}
if got.Proceeding.Code != "UPC_INF" {
t.Errorf("proceeding code = %q, want UPC_INF", got.Proceeding.Code)
}
})
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("inf.decision should be court-set; got IsCourtSet=false")
}
if got.DueDate != "" {
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
}
})
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
// is still surfaced so the UI can render the checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.def_to_ccr",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if len(got.FlagsRequired) == 0 || got.FlagsRequired[0] != "with_ccr" {
t.Errorf("FlagsRequired = %v, want [with_ccr]", got.FlagsRequired)
}
if len(got.FlagsApplied) != 0 {
t.Errorf("FlagsApplied = %v, want empty (flag not supplied)", got.FlagsApplied)
}
})
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if len(got.FlagsApplied) == 0 || got.FlagsApplied[0] != "with_ccr" {
t.Errorf("FlagsApplied = %v, want [with_ccr]", got.FlagsApplied)
}
})
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.sod",
TriggerDate: "",
})
if err == nil {
t.Errorf("expected error for empty triggerDate")
}
})
t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "totally.fake",
TriggerDate: "2026-01-15",
})
if err == nil {
t.Errorf("expected ErrUnknownRule, got nil")
}
})
}