Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
f6df857def fix(fristenrechner): m/paliad#57 — cleanup (Custom labels, forward-workflow root, same-context-twice, Add prefill)
Four UX cleanups on /tools/fristenrechner per m's 2026-05-20 14:02–14:04
report:

1. **Pre-fill project on 'Add'** — when Step 1 binds an Akte, both the
   Pathway A "Save to Project" modal and the Pathway B card-calc inline
   'Add' picker now default their <select> to that project. Override
   still allowed; the picker lists all projects. New helper
   `preselectedProjectId()` reads `currentStep1Context` once so both
   surfaces stay in sync.

2. **Drop 'Custom' prefix from UPC/DE/EPA/DPMA adhoc chips** — the
   chip context already reads "oder ad-hoc, ohne Akte"; 'Custom' was
   redundant signaling. Labels become "UPC-Verfahren" /
   "UPC proceeding" (and the three sister jurisdictions).

3. **Remove 'Ich möchte etwas einreichen' from 'Was ist passiert?'** —
   the Fristenrechner is a backward-looking calc ("event happened, what
   spawns?"); the forward-workflow framing ("I want to file X") needs a
   different tool. Filter the `ich-moechte-einreichen` root subtree out
   in `loadEventCategoryTree()` (HIDDEN_CASCADE_ROOTS set) so the picker
   never offers it. DB rows preserved for the future forward-workflow
   tool, tracked in m/paliad#65.

4. **Same-context-asked-twice on Statement-of-Defence picker** —
   when the user clicks a specific rule pill on a concept card, the
   calc panel now renders a locked "Kontext: <proceeding — rule>"
   caption with an "ändern" affordance instead of re-showing the same
   five proceedings as a radio fieldset. When the user clicks the card
   body (no specific pill), the picker is still the primary surface, but
   the card's rule-pill section hides via CSS while expanded
   (`fristen-card-pills-section--rules`) so the same options aren't
   listed twice. Cross-cutting trigger pills (Wiedereinsetzung,
   Weiterbehandlung etc.) stay visible — they're conceptually
   different siblings, not the same proceeding context.
2026-05-20 14:41:02 +02:00
5 changed files with 190 additions and 29 deletions

View File

@@ -251,6 +251,19 @@ function closeSaveModal() {
if (modal) modal.style.display = "none";
}
// preselectedProjectId returns the project the user picked in Step 1
// (if any) so the various save/add flows can default their project
// pickers to it. Carries through anywhere a "save to Akte" pop-out
// renders \u2014 preselection is *only* a default; the picker still
// renders every available project and the user can override.
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
// project should be pre-selected" on Add.
function preselectedProjectId(): string {
return currentStep1Context.kind === "project" && currentStep1Context.projectId
? currentStep1Context.projectId
: "";
}
async function openSaveModal() {
if (!lastResponse) return;
ensureSaveModal();
@@ -267,6 +280,7 @@ async function openSaveModal() {
sel.style.display = "";
noProjects.style.display = "none";
submit.disabled = false;
const preselected = preselectedProjectId();
sel.innerHTML = projects
.map((p) => {
const ref = (p.reference || "").trim();
@@ -274,9 +288,11 @@ async function openSaveModal() {
const label = ref
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: `${indent}${escHtml(p.title)}`;
return `<option value="${escAttr(p.id)}">${label}</option>`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
})
.join("");
if (preselected) sel.value = preselected;
}
const list = document.getElementById("frist-save-list")!;
@@ -1260,19 +1276,27 @@ function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
card.classList.add("is-expanded");
card.setAttribute("aria-expanded", "true");
const panel = buildCalcPanel(cardData, rulePills);
card.appendChild(panel);
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
// context is already known — the calc panel renders with that pill
// locked in and no "Which context?" picker. The card's pill list is
// hidden via CSS while is-expanded so the rules aren't listed twice.
// When the user clicked the card body (no autoSelectPill), the picker
// is the primary surface — still no duplicate pill list above it.
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
? rulePills.find((p) =>
p.proceeding?.code === autoSelectPill.dataset.proc
&& (autoSelectPill.dataset.focus
? p.rule_local_code === autoSelectPill.dataset.focus
: true))
: undefined;
// 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);
}
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
card.appendChild(panel);
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
const panel = document.createElement("div");
panel.className = "fristen-card-calc";
// stopPropagation so clicks inside the panel don't bubble to the
@@ -1283,10 +1307,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
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">
// Picker semantics (m/paliad#57 part 4):
// - lockedPill set → context known (user clicked a specific
// rule pill on the card). Render as a
// hidden input only; the calc panel shows
// no "Which context?" question. A small
// "ändern" link reopens the picker fieldset.
// - rulePills.length <= 1 → only one possible context, never a
// picker (hidden input carries the data).
// - otherwise → show the picker as primary surface; the
// card's pill list is hidden via CSS while
// the panel is open, so the user isn't
// asked the same thing twice.
let pickerHtml: string;
if (lockedPill) {
const procName = lockedPill.proceeding
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
: "";
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
pickerHtml = `<div class="fristen-card-calc-pill-locked">
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
</div>`;
} else if (rulePills.length <= 1) {
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
} else {
pickerHtml = `<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) : "";
@@ -1300,6 +1352,7 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
</label>`;
}).join("")}
</fieldset>`;
}
panel.innerHTML = `
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
@@ -1352,6 +1405,38 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLEle
void addCalcToProject(card, last);
});
// "ändern" — swap the locked-context caption for the full radio
// picker so the user can change context without collapsing the panel.
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
if (!card || !locked) return;
const fieldset = document.createElement("fieldset");
fieldset.className = "fristen-card-calc-pill-picker";
fieldset.setAttribute("role", "radiogroup");
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
fieldset.innerHTML = `
<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 || "";
const isChecked = (p.proceeding?.code || "") === lockedProc
&& (p.rule_local_code || "") === lockedFocus;
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "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("")}`;
locked.replaceWith(fieldset);
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => scheduleCardCalc(card, 0));
});
});
return panel;
}
@@ -1555,6 +1640,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const dueLabel = formatDate(calc.dueDate);
const preselected = preselectedProjectId();
msgEl.innerHTML = `
<div class="fristen-card-calc-add-picker">
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
@@ -1563,7 +1649,8 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
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>`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
}).join("")}
</select>
</label>
@@ -1573,6 +1660,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
`;
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
if (preselected) sel.value = preselected;
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
msgEl.innerHTML = "";
addBtn.disabled = false;
@@ -1642,12 +1730,12 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
const ruleSection = rulePills.length === 0 ? "" : `
<div class="fristen-card-pills-section">
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
const triggerSection = triggerPills.length === 0 ? "" : `
<div class="fristen-card-pills-section">
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
@@ -2423,6 +2511,17 @@ interface EventCategoryNode {
let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
// Top-level cascade roots that represent forward-looking workflows ("I
// want to file X, what deadlines does my action trigger?") rather than
// the backward-looking calc the Fristenrechner is built for ("event Y
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
// remove these from the "Was ist passiert?" picker — they belong in a
// future forward-workflow tool, not here. The DB rows stay so that
// future tool can pick them back up; we just hide them at the UI layer.
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
"ich-moechte-einreichen",
]);
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
if (eventCategoryTree) return eventCategoryTree;
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
@@ -2431,7 +2530,8 @@ async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
const r = await fetch("/api/tools/fristenrechner/event-categories");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
const raw = (data.tree || []) as EventCategoryNode[];
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
return eventCategoryTree;
} finally {
eventCategoryFetchInflight = null;

View File

@@ -272,10 +272,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1.divider.new": "oder eine neue Akte",
"deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
"deadlines.step1.new.cta": "+ Neue Akte anlegen",
"deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
"deadlines.step1.adhoc.de": "Custom DE-Verfahren",
"deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
"deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
"deadlines.step1.adhoc.upc": "UPC-Verfahren",
"deadlines.step1.adhoc.de": "DE-Verfahren",
"deadlines.step1.adhoc.epa": "EPA-Verfahren",
"deadlines.step1.adhoc.dpma": "DPMA-Verfahren",
"deadlines.step1.selected": "Akte:",
"deadlines.step1.reselect": "Andere Akte",
"deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
@@ -345,6 +345,8 @@ const translations: Record<Lang, Record<string, string>> = {
"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.pill_picker.locked_label": "Kontext:",
"deadlines.card.calc.pill_picker.change": "ändern",
"deadlines.card.calc.trigger.label": "Datum des auslösenden Ereignisses",
"deadlines.card.calc.flags.label": "Bedingungen:",
"deadlines.card.calc.flag.with_ccr": "Mit Nichtigkeitswiderklage",
@@ -2960,10 +2962,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step1.divider.new": "or a new matter",
"deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
"deadlines.step1.new.cta": "+ Create new matter",
"deadlines.step1.adhoc.upc": "Custom UPC proceeding",
"deadlines.step1.adhoc.de": "Custom DE proceeding",
"deadlines.step1.adhoc.epa": "Custom EPA proceeding",
"deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
"deadlines.step1.adhoc.upc": "UPC proceeding",
"deadlines.step1.adhoc.de": "DE proceeding",
"deadlines.step1.adhoc.epa": "EPA proceeding",
"deadlines.step1.adhoc.dpma": "DPMA proceeding",
"deadlines.step1.selected": "Matter:",
"deadlines.step1.reselect": "Other matter",
"deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
@@ -3040,6 +3042,8 @@ const translations: Record<Lang, Record<string, string>> = {
"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.pill_picker.locked_label": "Context:",
"deadlines.card.calc.pill_picker.change": "change",
"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",

View File

@@ -161,19 +161,19 @@ export function renderFristenrechner(): string {
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
data-i18n="deadlines.step1.adhoc.upc">
Custom UPC proceeding
UPC proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
data-i18n="deadlines.step1.adhoc.de">
Custom DE proceeding
DE proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
data-i18n="deadlines.step1.adhoc.epa">
Custom EPA proceeding
EPA proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
data-i18n="deadlines.step1.adhoc.dpma">
Custom DPMA proceeding
DPMA proceeding
</button>
</div>
</div>

View File

@@ -971,7 +971,9 @@ export type I18nKey =
| "deadlines.card.calc.flag.with_cci"
| "deadlines.card.calc.flag.with_ccr"
| "deadlines.card.calc.flags.label"
| "deadlines.card.calc.pill_picker.change"
| "deadlines.card.calc.pill_picker.label"
| "deadlines.card.calc.pill_picker.locked_label"
| "deadlines.card.calc.result.calculating"
| "deadlines.card.calc.result.court_set"
| "deadlines.card.calc.result.due"

View File

@@ -2670,6 +2670,61 @@ input[type="range"]::-moz-range-thumb {
font-family: ui-monospace, monospace;
}
/* m/paliad#57 part 4 — once a card is expanded into a calc panel,
the rule-pill list is redundant with the calc panel's context
picker (locked caption or fieldset). Hide it so the user isn't
asked the same thing twice. The cross-cutting section stays —
those pills are alternative concepts to explore, not the same
proceeding context. */
.fristen-card.is-expanded .fristen-card-pills-section--rules {
display: none;
}
/* Locked-context caption when the user clicked a specific rule pill
to expand. Shows the picked (proceeding, rule) tuple compactly
with a small "ändern" button to swap back to the radio picker. */
.fristen-card-calc-pill-locked {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 5px;
background: rgba(198, 244, 28, 0.06);
font-size: 0.88rem;
}
.fristen-card-calc-pill-locked-label {
font-weight: 600;
color: var(--color-muted, #777);
text-transform: uppercase;
font-size: 0.74rem;
letter-spacing: 0.04em;
}
.fristen-card-calc-pill-locked-proc {
font-weight: 600;
color: var(--color-text, #222);
}
.fristen-card-calc-pill-locked-rule {
color: var(--color-text, #222);
}
.fristen-card-calc-pill-locked-source {
font-size: 0.8rem;
color: var(--color-muted, #888);
font-family: ui-monospace, monospace;
}
.fristen-card-calc-pill-change {
margin-left: auto;
background: transparent;
border: 0;
padding: 0;
color: var(--color-link, #1267a8);
cursor: pointer;
font-size: 0.82rem;
text-decoration: underline;
}
.fristen-card-calc-pill-change:hover { text-decoration: none; }
.fristen-card-calc-inputs {
display: flex;
flex-wrap: wrap;