Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
a28a72679a feat(projection): IsConditional for uncertain-anchor rules (t-paliad-289)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.

Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
  main loop, so order-of-evaluation in sequence_order no longer matters
  for the parent-court-set check. Fixes R.109(1) "Antrag auf
  Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
  sequence_order=50): the timing='before' backward arithmetic was
  computing 1 month before the trigger date because the court-set
  parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
  R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
  rules whose data-model parent is the trigger anchor (covers R.262(2)
  confidentiality_response: the data anchors on SoC, but the real
  trigger is the opposing party's confidentiality motion which may
  never happen). Suppressed by IsOverridden so user anchors win.

Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
  DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
  carries the "abhängig von <parent>" payload even when the parent
  has no computed date for annotateDependsOn to discover.

Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
  click-to-edit affordance in place of the date column when
  isConditional=true. IsConditional wins over IsCourtSet for the
  date column (they overlap; "abhängig von <parent>" names the
  specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
  dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
  escape so the module is testable in bun test without jsdom (the
  old form forced fixtures to leave several fields empty just to
  avoid the DOM dependency).

Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
  that conditional rows pass through applyLookaheadCap untouched
  (don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
  asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
  IsConditional=true with empty DueDate + populated ParentRule*; SoD
  stays non-conditional; override on the oral hearing flips R.109(1)
  back to concrete date.
- 4 new bun tests for the conditional rendering branches in
  deadlineCardHtml.

UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.

go build / go test / bun test / bun run build all clean.
2026-05-26 09:47:28 +02:00
9 changed files with 514 additions and 21 deletions

View File

@@ -254,6 +254,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.conditional.depends_on": "abhängig von {parent}",
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
"deadlines.optional.badge": "auf Antrag",
"deadlines.priority.mandatory": "Pflicht",
"deadlines.priority.recommended": "empfohlen",
@@ -3351,6 +3353,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.conditional.depends_on": "depends on {parent}",
"deadlines.conditional.unset": "depends on an upstream event",
"deadlines.optional.badge": "on request",
"deadlines.priority.mandatory": "Mandatory",
"deadlines.priority.recommended": "Recommended",

View File

@@ -67,6 +67,66 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
});
});
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
// chip in place of the date column, and the chip keeps the click-to-edit
// affordance so the user can pin a real date once the upstream anchor
// resolves (oral hearing scheduled, opposing party's motion received, …).
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
// the issue.
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
const html = deadlineCardHtml(
dl({
code: "upc.inf.cfi.translation_request",
isConditional: true,
parentRuleCode: "upc.inf.cfi.oral",
parentRuleName: "Mündliche Verhandlung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von Mündliche Verhandlung");
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
expect(html).toContain('role="button"');
expect(html).not.toContain("timeline-court-set");
});
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
const html = deadlineCardHtml(
dl({ isConditional: true }),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von vorgelagertem Ereignis");
});
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
// Court-set ancestor without override sets BOTH isCourtSet=true AND
// isConditional=true on the wire. The renderer must pick the
// conditional chip; otherwise the row keeps the legacy "wird vom
// Gericht bestimmt" label and the user can't see WHICH upstream
// event blocks them.
const html = deadlineCardHtml(
dl({
isConditional: true,
isCourtSet: true,
isCourtSetIndirect: true,
parentRuleName: "Entscheidung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("abhängig von Entscheidung");
expect(html).not.toContain("timeline-court-set");
});
test("isConditional=false keeps the normal date span (regression guard)", () => {
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
expect(html).toContain("timeline-date");
expect(html).not.toContain("timeline-conditional");
});
});
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the

View File

@@ -72,6 +72,24 @@ export interface CalculatedDeadline {
// page-level appellant axis still applies in that case). The bucketer
// reads this in preference to the page-level appellant.
appellantContext?: string;
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
// no concrete date is projected. Set by the calculator when the rule
// depends on a court-set ancestor without override, when a backward-
// anchored rule's forward anchor isn't set, or for optional rules
// whose true triggering event sits outside the rule data (e.g.
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
// in the data, but the real trigger is the opposing party's
// confidentiality motion). The renderer drops the date column entry
// and shows an "abhängig von <parentRuleName>" chip instead.
isConditional?: boolean;
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
// parent rule's identity so the renderer can label the
// "abhängig von <parent>" chip on conditional rows. Populated for
// every rule with a parent (not just conditional ones), so the
// dependency-footer logic can reuse it. Empty for root rules.
parentRuleCode?: string;
parentRuleName?: string;
parentRuleNameEN?: string;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -175,10 +193,20 @@ export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// Pure-string HTML escape — keeps the module testable in bun test
// (plain Node, no jsdom). Used to be backed by document.createElement,
// which forced fixtures to leave any field that flowed through it
// empty just to exercise unrelated branches; the regex form is safe
// for arbitrary text including the per-rule name strings that the
// conditional-row chip ("abhängig von <parent>") now exposes.
// (t-paliad-289)
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function formatDate(dateStr: string): string {
@@ -279,12 +307,31 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
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"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<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>`;
// Conditional rows (t-paliad-289) replace the date column with an
// "abhängig von <parent>" chip. The chip remains click-to-edit so
// the user can pin a real date once known (e.g. once the oral
// hearing date is set, or the opposing party's Vertraulichkeits-
// antrag arrives) — the same data-rule-code wiring fires the
// existing inline date editor. IsConditional wins over IsCourtSet:
// they overlap (court-set ancestor without override produces both),
// and "abhängig von <parent>" is the clearer user-facing signal.
const parentLabel = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: dl.parentRuleName) || "";
let dateStr: string;
if (dl.isConditional) {
const chipText = parentLabel
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
: t("deadlines.conditional.unset");
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
} else if (dl.isCourtSet) {
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
} else {
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
}
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
// priority directly. Optional badge fires only on 'optional'
@@ -449,8 +496,16 @@ export function wireDateEditClicks(
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
const itemClasses = [
"timeline-item",
dl.isRootEvent ? "timeline-root" : "",
// t-paliad-289: dotted-border + faded styling for conditional rows
// so the "abhängig von <parent>" state is visually distinct from
// both anchored deadlines and direct court-set rows.
dl.isConditional ? "timeline-item--conditional" : "",
].filter(Boolean).join(" ");
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="${itemClasses}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
@@ -629,7 +684,14 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
const itemClasses = [
"fr-col-item",
dl.isRootEvent ? "fr-col-root" : "",
// t-paliad-289: same conditional treatment as the linear
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;

View File

@@ -1235,6 +1235,8 @@ export type I18nKey =
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.complete.confirm"
| "deadlines.conditional.depends_on"
| "deadlines.conditional.unset"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"

View File

@@ -3531,6 +3531,35 @@ input[type="range"]::-moz-range-thumb {
opacity: 0.55;
}
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
without override, backward-anchor with unset forward date, optional
event not recorded). The "abhängig von <parent>" chip on the date
column makes the conditional state explicit; the dotted border on
the content panel + slight desaturation reinforces it at glance so
the row reads as "pending an upstream input" rather than as a real
scheduled item. The frist-date-edit affordance on the chip still
wires through — the user can pin a concrete date once the anchor
resolves. */
.timeline-item--conditional .timeline-content,
.fr-col-item--conditional {
border: 1px dashed var(--color-border, #d4d4d4);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--color-bg-soft, #fafafa);
}
.timeline-item--conditional .timeline-name,
.fr-col-item--conditional .timeline-name {
opacity: 0.85;
}
.timeline-conditional {
font-size: 0.82rem;
color: var(--color-text-muted);
font-style: italic;
text-align: right;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);

View File

@@ -89,6 +89,35 @@ type UIDeadline struct {
// is computed off a parent date that the COURT sets, not by the
// court itself.
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
// IsConditional signals the rule's anchor is uncertain — no
// concrete date can be projected. Set when the rule depends on:
// - a court-set ancestor whose date isn't anchored
// (overlaps with IsCourtSetIndirect; the two are kept
// distinct because IsCourtSet wraps a specific UX message
// "wird vom Gericht bestimmt", whereas IsConditional is
// the broader "render as 'abhängig von <parent>'" signal)
// - timing='before' rules whose forward anchor isn't set
// (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month
// before the oral hearing — without the hearing date, the
// backward arithmetic against the trigger date is meaningless)
// - optional opposing-side rules whose true triggering event
// hasn't been recorded for this project (e.g. R.262(2)
// Erwiderung auf Vertraulichkeitsantrag — the data-model
// parent is the SoC, but the real trigger is the opposing
// party's confidentiality motion which may never happen)
// When true, DueDate and OriginalDate are empty and the frontend
// renders an "abhängig von <ParentRuleName>" chip in place of a
// date. Suppressed by an explicit user anchor (IsOverridden wins).
// (t-paliad-289)
IsConditional bool `json:"isConditional,omitempty"`
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
// parent's identity so the frontend can render
// "abhängig von <ParentRuleName>" when IsConditional=true.
// Populated whenever the rule has a parent_id, not only when
// conditional — keeps the wire shape stable. Empty for root rules.
ParentRuleCode string `json:"parentRuleCode,omitempty"`
ParentRuleName string `json:"parentRuleName,omitempty"`
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
// the rule so the frontend knows whether to render the per-event-card
@@ -372,6 +401,30 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]UIDeadline, 0, len(rules))
// Pre-pass: identify rules flagged is_court_set=true in the data so
// order-of-evaluation in sequence_order doesn't matter for the
// parent-court-set check below. Without this, a rule processed
// earlier than its court-set parent (e.g. R.109(1) Antrag auf
// Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung
// sequence_order=50 in upc.inf.cfi) misses the court-set propagation
// and computes a meaningless date — for timing='before' rules, that
// produces a backward offset from the trigger date, which has no
// semantic relationship to the rule. (t-paliad-289)
for _, r := range rules {
if r.IsCourtSet {
courtSet[r.ID] = true
}
}
// ruleByID lets the conditional-rendering branches resolve a parent
// rule's display fields (submission_code, name, name_en) for the
// "abhängig von <ParentRuleName>" chip without re-scanning the
// rules slice on every iteration.
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
for _, r := range rules {
ruleByID[r.ID] = r
}
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
// for membership tests; the engine reads them but doesn't mutate.
skipRules := opts.SkipRules
@@ -464,21 +517,34 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.NotesEN = *r.DeadlineNotesEn
}
// Resolve the parent rule once so every conditional-rendering
// branch (incl. the optional-not-recorded path below) can stamp
// ParentRule* on the wire without re-scanning. Populated even
// for non-conditional rows — the frontend dependency-footer
// ("Folgt aus …") already consumes this on regular projected
// rows. (t-paliad-289)
var parentRule *models.DeadlineRule
if r.ParentID != nil {
if pr, ok := ruleByID[*r.ParentID]; ok {
parentRule = &pr
if pr.SubmissionCode != nil {
d.ParentRuleCode = *pr.SubmissionCode
}
d.ParentRuleName = pr.Name
d.ParentRuleNameEN = pr.NameEN
}
}
// Propagate court-set status from a parent rule whose date the
// court determines: if the anchor itself has no real date,
// nothing downstream can be computed either — UNLESS the user
// has supplied an override date for the parent (which they can
// once they know the real decision date).
parentOverridden := false
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
break
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
if parentRule.SubmissionCode != nil {
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
parentOverridden = true
}
}
}
@@ -530,6 +596,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// "unbestimmt", not "wird vom Gericht bestimmt".
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -563,6 +630,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// itself isn't a court action.
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -595,9 +663,19 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// 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.
//
// timing='before' rules end up here too — a rule with
// "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the
// oral hearing as its parent; if the hearing date isn't set,
// the backward arithmetic against the trigger date is
// meaningless. The pre-pass above ensures courtSet[oral.ID]
// is true even when the oral hearing rule is processed later
// in sequence_order. IsConditional surfaces the "abhängig
// von <ParentRuleName>" UX. (t-paliad-289)
if parentIsCourtSet {
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -700,6 +778,42 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
// Rules with priority='optional' AND primary_party='both' whose
// data-model parent is the proceeding's trigger anchor (parent
// has parent_id=NULL and is not court-set, i.e. the SoC root
// rule) represent a rule whose REAL triggering event sits
// outside the rule data — e.g. R.262(2) Erwiderung auf
// Vertraulichkeitsantrag anchors on SoC in the data, but the
// real trigger is the opposing party's confidentiality motion
// which may never happen. Without an explicit anchor on the
// rule itself (user clicks "Datum setzen" after the motion
// arrives), the projection must NOT claim a concrete date.
//
// In the live corpus this catches confidentiality_response;
// every other optional+both rule has a court-set ancestor and
// is already caught by the parentIsCourtSet branches above.
// Suppressed when IsOverridden (the user has anchored the rule
// — the date is real) or when the rule has already been marked
// IsConditional by an earlier branch.
if !d.IsOverridden && !d.IsConditional &&
r.Priority == "optional" &&
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
d.WasAdjusted = false
d.AdjustmentReason = nil
// Mark this rule's ID as having an uncertain anchor so
// rules chaining off it also surface conditional via the
// parentIsCourtSet path (no rule currently chains off
// confidentiality_response in the live corpus, but the
// extension keeps the propagation semantics consistent).
courtSet[r.ID] = true
}
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}

View File

@@ -450,3 +450,130 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}
// t-paliad-289: rules anchored on uncertain triggers must render as
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
// rather than fabricating a date off the trigger.
//
// Three pillars from the issue:
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
// computed a meaningless "1 month before today" because sequence_order
// places translation_request (45) before oral (50), so the parent
// hadn't been classified as court-set yet. The new pre-pass in
// Calculate seeds courtSet from is_court_set=true on the data, so
// order-of-evaluation no longer matters.
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
// worked via the legacy IsCourtSetIndirect path; assertion ensures
// the new IsConditional flag rides alongside it.
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
// primary_party='both', parent=SoC which is the trigger anchor).
// The data-model parent is "always certain" but the real triggering
// event (opposing party's confidentiality motion) sits outside the
// rule data — render conditional until the user anchors the rule.
func TestUIDeadline_IsConditional_UncertainAnchors(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)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := map[string]UIDeadline{}
for _, d := range resp.Deadlines {
byCode[d.Code] = d
}
cases := []struct {
code string
wantConditional bool
wantParentCode string
}{
// Symptom A — backward-anchored on the court-set oral hearing.
// Pre-pass fix: order-of-evaluation no longer matters.
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
// R.118(4) chain — parent=decision (court-set).
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
// Symptom B — optional + both anchored on SoC (trigger anchor).
{"upc.inf.cfi.confidentiality_response", true, "upc.inf.cfi.soc"},
// Negative control — mandatory rule anchored on SoC must keep
// its concrete date (no IsConditional, real DueDate).
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
}
for _, c := range cases {
t.Run(c.code, func(t *testing.T) {
d, ok := byCode[c.code]
if !ok {
t.Fatalf("rule %s missing from response", c.code)
}
if d.IsConditional != c.wantConditional {
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
}
if c.wantConditional {
if d.DueDate != "" {
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
}
if d.ParentRuleCode != c.wantParentCode {
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
}
if d.ParentRuleName == "" {
t.Errorf("ParentRuleName empty for conditional rule")
}
} else {
if d.DueDate == "" {
t.Errorf("non-conditional rule has empty DueDate")
}
}
})
}
// Override path: when the user anchors the oral hearing, the
// backward-anchored R.109(1) flips back to a concrete date and
// IsConditional clears. This is the click-to-edit unblock.
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
AnchorOverrides: map[string]string{
"upc.inf.cfi.oral": "2027-03-01",
},
})
if err != nil {
t.Fatalf("Calculate with override: %v", err)
}
var tr UIDeadline
for _, d := range resp2.Deadlines {
if d.Code == "upc.inf.cfi.translation_request" {
tr = d
break
}
}
if tr.IsConditional {
t.Errorf("translation_request IsConditional=true after oral override; want false")
}
if tr.DueDate == "" {
t.Errorf("translation_request DueDate empty after oral override")
}
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
}
})
}

View File

@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
}
}
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
// pass through applyLookaheadCap untouched — they're not "future
// predicted" rows by either Status or Date semantics, so they belong in
// the pass-through bucket alongside court_set / undated rows. The cap
// must NOT consume one of its slots for a conditional row, and the
// row must survive even when projTotal exceeds the cap.
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
// Two conditional — must survive uncapped, must NOT count
// against projTotal / projShown.
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 0 {
t.Errorf("PredictedOverdue = %d, want 0", overdue)
}
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
if len(kept) != 4 {
t.Errorf("kept rows = %d, want 4", len(kept))
}
keptTitles := map[string]bool{}
for _, r := range kept {
keptTitles[r.Title] = true
}
for _, want := range []string{"F1", "F2", "C1", "C2"} {
if !keptTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if keptTitles["F3"] {
t.Errorf("F3 should have been dropped (cap=2)")
}
}
func TestRuleAnchorKind(t *testing.T) {
hearing := "hearing"
decision := "decision"

View File

@@ -147,6 +147,17 @@ type TimelineEvent struct {
// checkbox). At parent-node levels, rows with BubbleUp=true survive
// the levelPolicy kind/status filter unconditionally.
BubbleUp bool `json:"bubble_up,omitempty"`
// IsConditional marks projected rows whose anchor is uncertain —
// the projection layer mirrors UIDeadline.IsConditional from the
// fristenrechner so the SmartTimeline can render an "abhängig von
// <parent>" chip in place of the date column. When true, Date is
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
// reference (already populated by annotateDependsOn for projected
// rows; for conditional rows we additionally fall back to the
// UIDeadline-supplied ParentRule* when the parent has no
// computed date). Status is set to "conditional". (t-paliad-289)
IsConditional bool `json:"is_conditional,omitempty"`
}
// LaneInfo describes one column in the parent-node aggregated view.
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
RuleCode: ui.Code,
DeadlineRuleParty: ui.Party,
IsConditional: ui.IsConditional,
}
idCopy := ruleID
ev.DeadlineRuleID = &idCopy
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
// court-set rules whose date isn't bound yet.
// court-set / conditional rules whose date isn't bound yet.
if ui.DueDate != "" {
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
}
}
// Conditional rows from the fristenrechner (t-paliad-289):
// pre-stamp the dependency reference here so the row carries
// the "abhängig von <parent>" payload even when the parent has
// no computed date for annotateDependsOn to pick up later.
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
// and the parent's actual date (if anchored elsewhere) still
// flows into DependsOnDate via the actuals-first preference.
if ui.IsConditional && ui.ParentRuleCode != "" {
ev.DependsOnRuleCode = ui.ParentRuleCode
switch lang(opts.Lang) {
case "en":
if ui.ParentRuleNameEN != "" {
ev.DependsOnRuleName = ui.ParentRuleNameEN
} else {
ev.DependsOnRuleName = ui.ParentRuleName
}
default:
ev.DependsOnRuleName = ui.ParentRuleName
}
}
switch {
case ui.IsConditional:
// Anchor uncertain (court-set ancestor without override,
// backward-anchor without forward date, or optional event
// not recorded). Surface as conditional so the frontend
// renders "abhängig von <parent>" in place of a date.
// Conditional rows must not carry a date even if the
// calculator left one — clear it to match the wire contract.
// (t-paliad-289)
ev.Date = nil
ev.Status = "conditional"
case ui.IsCourtSet && ev.Date == nil:
// Pure court-set rule — date is bound by the court at
// hearing/decision time. Surface as undated court_set.