@@ -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 , ruleP ills ) ;
card . appendChild ( panel ) ;
// m/paliad#57 part 4: when the user clicked a specific rule p ill, 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 p ill; 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 , ruleP ills , 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 ( ruleP ills [ 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 p ill 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 ;