feat(fristenrechner): "unbestimmt" for chained court-set rules (m's R.151 case)

m's 2026-05-08 17:50 feedback: 'Antrag auf Kostenentscheidung' (RoP.151)
labels itself "wird vom Gericht bestimmt" but the rule is actually
"1 Monat ab Hauptentscheidung". The court doesn't directly determine
this date — it determines the parent's date (Hauptentscheidung) and
this rule chains off that. Calling it "vom Gericht bestimmt" overstates
the relationship; "unbestimmt" reads correctly: derived from a
not-yet-known anchor.

Two failure modes split:

  - Direct court-set    rule itself is hearing / decision / order
                        (or primary_party='court'). Label stays
                        "wird vom Gericht bestimmt" — strictly correct.
  - Indirect court-set  rule has a real duration but its anchor is a
                        court-set parent (RoP.151 case), or it's a
                        zero-duration rule whose parent is court-set
                        without a real date. Label flips to
                        "unbestimmt".

Backend: new `IsCourtSetIndirect bool` on UIDeadline, set on the three
indirect cases inside FristenrechnerService.Calculate. Direct cases
keep IsCourtSetIndirect=false so their label stays unchanged. JSON
omits the field when false, no consumer churn.

Frontend: deadlineCardHtml + the save-modal row both consult
IsCourtSetIndirect to pick between two i18n keys (deadlines.court.set
"vom Gericht bestimmt" and deadlines.court.indirect "unbestimmt"; EN
falls back to "set by court" / "tbd"). The override edit affordance
keeps working unchanged — user types the actual parent date, downstream
re-flows.

Refs m/paliad#15 (m's 2026-05-08 17:50 feedback Item 1).
This commit is contained in:
m
2026-05-08 17:55:22 +02:00
parent ac15911e4f
commit ef78f59d25
4 changed files with 56 additions and 3 deletions

View File

@@ -37,6 +37,11 @@ interface CalculatedDeadline {
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
// True when isCourtSet is "unbestimmt" — the rule chains off a
// court-determined parent (e.g. RoP.151 = 1 Monat ab
// Hauptentscheidung) rather than being itself court-set. The UI
// renders "unbestimmt" instead of "wird vom Gericht bestimmt".
isCourtSetIndirect?: boolean;
isOverridden?: boolean;
}
@@ -377,8 +382,12 @@ async function openSaveModal() {
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
const disabled = isCourtDetermined || !dl.dueDate;
const checked = !disabled;
// Same direct-vs-indirect split as the timeline date cell —
// chained court-set rules read as "unbestimmt" rather than
// "wird vom Gericht bestimmt".
const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set";
const meta = isCourtDetermined
? `<span class="frist-save-meta">${escHtml(t("deadlines.court.set"))}</span>`
? `<span class="frist-save-meta">${escHtml(t(courtLabelKey))}</span>`
: `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}</span>`;
return `<li class="frist-save-row">
<label>
@@ -536,8 +545,16 @@ function deadlineCardHtml(dl: CalculatedDeadline, opts: { showParty: boolean }):
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
// "wird vom Gericht bestimmt" only fits direct court-set rules
// (Urteil / Beschluss / Anordnung). Indirect rules (chained off a
// court-set parent, e.g. RoP.151) render "unbestimmt" instead — the
// date isn't directly determined by the court, it's derived from
// the parent's date that the court will set. m's 2026-05-08 call.
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t("deadlines.court.set")}</span>`
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
const mandatoryBadge = dl.isMandatory

View File

@@ -243,6 +243,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both": "Beide",
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.date.edit.hint": "Datum bearbeiten — Folgefristen werden neu berechnet",
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
@@ -2307,6 +2308,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both": "Both",
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.date.edit.hint": "Edit date — downstream deadlines will recalculate",
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",

View File

@@ -722,6 +722,7 @@ export type I18nKey =
| "deadlines.col.status"
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
| "deadlines.date.edit.hint"

View File

@@ -51,6 +51,18 @@ type UIDeadline struct {
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsRootEvent bool `json:"isRootEvent"`
IsCourtSet bool `json:"isCourtSet"`
// IsCourtSetIndirect is true when IsCourtSet is true because the
// rule chains off a court-determined parent (e.g. RoP.151
// Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the
// Hauptentscheidung itself is the court-set anchor). Direct
// court-determined rules (Urteil / Beschluss / Anordnung
// themselves) keep IsCourtSet=true with IsCourtSetIndirect=false.
// The frontend uses this to render "unbestimmt" for indirect
// cases instead of "wird vom Gericht bestimmt", which is only
// strictly correct for the direct ones — the indirect deadline
// is computed off a parent date that the COURT sets, not by the
// court itself.
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
}
@@ -293,7 +305,11 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// If parent is court-set, we have nothing to inherit —
// fall through to court-set marking.
if parentIsCourtSet {
// Indirect: this rule isn't itself court-determined,
// it's blocked because its parent is. UI should say
// "unbestimmt", not "wird vom Gericht bestimmt".
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -321,14 +337,23 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
computed[*r.Code] = parentDate
}
} else {
// Parent not yet computed (defensive — shouldn't
// happen given sequence_order). Treat as indirect
// court-set: the date is unknown but the rule
// itself isn't a court action.
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
}
}
} else {
// Buckets 2 + 3: court-determined.
// Buckets 2 + 3: court-determined directly (the rule
// itself is a hearing / decision / order or has
// primary_party='court'). The label "wird vom Gericht
// bestimmt" is strictly correct here — keep
// IsCourtSetIndirect=false.
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
@@ -343,8 +368,16 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// than fabricating one off the trigger date. The user can re-run
// with the actual decision date once the court issues it (or
// supplied via AnchorOverrides).
//
// This is the RoP.151 case (Antrag auf Kostenentscheidung is
// "1 Monat ab Hauptentscheidung") — the rule has a real
// duration but its anchor is the court-set parent. The UI
// should say "unbestimmt", not "wird vom Gericht bestimmt":
// the date isn't directly determined by the court, it's
// derived from a date the court sets.
if parentIsCourtSet {
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true