feat(t-paliad-119): explain WHY a Fristenrechner deadline was shifted

The current "Wochenende/Feiertag" / "weekend/holiday" label hides the cause
of long shifts — m's reproduction had a deadline jump from 4.8.2026 to
31.8.2026 (+27 calendar days) across UPC Summer Vacation, and the UI made
it look like a bug. The math was correct; the explanation was lying.

Backend:
- AdjustForNonWorkingDaysWithReason returns an AdjustmentReason alongside
  the adjusted date. Walks the same 60-iter loop, classifies the dominant
  cause (vacation > public_holiday > weekend), collects every named
  holiday hit, and for vacations scans outward to report the contiguous
  block boundary (27.7.–28.8., not the 25 individual rows).
- AdjustForNonWorkingDays now wraps the new method, preserving its
  3-tuple signature for existing callers (deadline_calculator,
  event_deadline_service).
- UIDeadline gains an AdjustmentReason field; FristenrechnerService
  populates it on every shifted deadline.
- Date fields serialise as YYYY-MM-DD strings (HolidayDTO + string
  vacation span) — the Fristenrechner client already speaks that format.

Frontend:
- AdjustmentReason → human-readable phrase via renderAdjustmentReason:
    vacation       → "{vacation_name} ({span})"
    public_holiday → "Feiertag ({first_holiday_name})" / "{name} holiday"
    weekend        → "Wochenende" / localised weekday
- Surrounding format becomes "Verschoben wegen X: A → B" (DE) or
  "Shifted (X): A → B" (EN). Falls back to the legacy reason string
  when the backend hasn't sent a structured reason.
- Vacation names render verbatim from paliad.holidays — no hardcoded
  i18n mapping for individual closures (those rotate via the seed, not
  via i18n.ts).

Tests cover the three Kind paths plus the no-shift case; UPC vacation
test injects the migration-010 seed into the cache so the assertion
runs without a live DB.

Out of scope (raised in conversation, deferred):
- Whether "UPC Summer Vacation" / "UPC Winter Vacation" are the right
  names for the seeded rows, and whether the winter block belongs in
  paliad.holidays at all (m flagged this as BS while reviewing the
  task — needs a data-side decision before renaming/removing).
- holidays.country isn't filtered by proceeding-type jurisdiction, so
  UPC vacation currently shifts EP_GRANT / EPA_APP / German national
  deadlines too. Bigger fix; flagged for a follow-up issue.
This commit is contained in:
m
2026-05-04 18:31:55 +02:00
parent ecfd62e330
commit d688ebde90
6 changed files with 372 additions and 17 deletions

View File

@@ -4,6 +4,22 @@
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
interface CalculatedDeadline {
code: string;
name: string;
@@ -16,6 +32,7 @@ interface CalculatedDeadline {
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
}
@@ -71,6 +88,68 @@ function partyBadge(party: string): string {
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}
// Short date span like "27.7.28.8." (DE) or "27 Jul 28 Aug" (EN). Used in
// the vacation adjustment label, where the explicit weekday + year would
// just be noise — the surrounding sentence carries the full year via the
// dueDate / originalDate that the note brackets.
function formatDateSpan(startISO: string, endISO: string): string {
const start = new Date(startISO + "T00:00:00");
const end = new Date(endISO + "T00:00:00");
if (getLang() === "en") {
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
return `${fmt(start)}${fmt(end)}`;
}
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). The Fristenrechner doesn't translate them: they're proper
// names of court-set closures, not generic strings, and rotating them via
// i18n.ts duplicates state that should live in the DB. Rename in the seed
// if the wording needs to change.
function localizeVacationName(name: string): string {
return name;
}
function localizeWeekday(en: string): string {
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
return en;
}
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
// (27.7.28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
function renderAdjustmentReason(r: AdjustmentReason): string {
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
const span = formatDateSpan(r.vacation_start, r.vacation_end);
return tDyn("deadlines.adjusted.vacation")
.replace("{name}", localizeVacationName(r.vacation_name))
.replace("{span}", span);
}
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
}
if (r.kind === "weekend" && r.original_weekday) {
return localizeWeekday(r.original_weekday);
}
return t("deadlines.adjusted.weekend");
}
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
// structured reason — keeps older API responses readable.
function formatAdjustedNote(dl: CalculatedDeadline): string {
const arrow = `${formatDate(dl.originalDate)}${formatDate(dl.dueDate)}`;
const reason = dl.adjustmentReason
? renderAdjustmentReason(dl.adjustmentReason)
: t("deadlines.adjusted.reason");
if (getLang() === "en") {
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
}
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}
let selectedType = "";
function showStep(n: number) {
@@ -331,7 +410,7 @@ function renderTimeline(data: DeadlineResponse) {
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">\u26a0 ${t("deadlines.adjusted")}: ${formatDate(dl.originalDate)} \u2192 ${formatDate(dl.dueDate)} (${t("deadlines.adjusted.reason")})</div>`
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef

View File

@@ -261,6 +261,11 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.event.timing.after": "nach",
"deadlines.adjusted": "Verschoben",
"deadlines.adjusted.reason": "Wochenende/Feiertag",
"deadlines.adjusted.weekend": "Wochenende",
"deadlines.adjusted.weekend.saturday": "Samstag",
"deadlines.adjusted.weekend.sunday": "Sonntag",
"deadlines.adjusted.holiday": "Feiertag ({name})",
"deadlines.adjusted.vacation": "{name} ({span})",
// Downloads
"downloads.title": "Downloads \u2014 Paliad",
@@ -1736,6 +1741,11 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.court.set": "set by court",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",
"deadlines.adjusted.weekend.saturday": "Saturday",
"deadlines.adjusted.weekend.sunday": "Sunday",
"deadlines.adjusted.holiday": "{name} holiday",
"deadlines.adjusted.vacation": "{name} ({span})",
// Trigger-event mode (PR-2 — youpc-parity)
"deadlines.mode.procedure": "Course of proceedings",
"deadlines.mode.event": "What comes after…",

View File

@@ -533,7 +533,12 @@ export type I18nKey =
| "dashboard.when.tomorrow"
| "deadlines.action.reopen"
| "deadlines.adjusted"
| "deadlines.adjusted.holiday"
| "deadlines.adjusted.reason"
| "deadlines.adjusted.vacation"
| "deadlines.adjusted.weekend"
| "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday"
| "deadlines.calculate"
| "deadlines.col.akte"
| "deadlines.col.due"

View File

@@ -34,20 +34,21 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
type UIDeadline struct {
RuleID string `json:"ruleId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Party string `json:"party"`
IsMandatory bool `json:"isMandatory"`
RuleRef string `json:"ruleRef"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"`
OriginalDate string `json:"originalDate"`
WasAdjusted bool `json:"wasAdjusted"`
IsRootEvent bool `json:"isRootEvent"`
IsCourtSet bool `json:"isCourtSet"`
RuleID string `json:"ruleId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`
Party string `json:"party"`
IsMandatory bool `json:"isMandatory"`
RuleRef string `json:"ruleRef"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"`
OriginalDate string `json:"originalDate"`
WasAdjusted bool `json:"wasAdjusted"`
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
IsRootEvent bool `json:"isRootEvent"`
IsCourtSet bool `json:"isCourtSet"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
@@ -250,11 +251,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
endDate := addDuration(baseDate, durationValue, durationUnit)
origDate := endDate
adjusted, _, wasAdj := s.holidays.AdjustForNonWorkingDays(endDate)
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
d.OriginalDate = origDate.Format("2006-01-02")
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
if r.Code != nil {
computed[*r.Code] = adjusted
}

View File

@@ -132,13 +132,157 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
// — caught by the t-paliad-086 PR-2 smoke test (Statement of Defence on
// 2026-04-30 → adjusted incorrectly to 2026-08-29 instead of 2026-08-31).
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
adjusted, original, wasAdjusted, _ = s.AdjustForNonWorkingDaysWithReason(date)
return adjusted, original, wasAdjusted
}
// AdjustmentReason explains why a deadline was shifted off the originally
// computed date. The Fristenrechner UI uses it to render specific labels
// — "UPC-Sommerferien (27.7.28.8.)" instead of the generic "Wochenende/
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
// math bug. See t-paliad-119.
//
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
// separate RFC3339 parser. Holidays carries the same string-date shape.
type AdjustmentReason struct {
// Kind is the dominant cause; longest cause wins when several apply
// (vacation > public_holiday > weekend).
Kind string `json:"kind"`
// Holidays collects every named holiday encountered while walking past
// the non-working run, deduped by (date, name). May be empty when the
// only cause is a weekend.
Holidays []HolidayDTO `json:"holidays,omitempty"`
// VacationName, VacationStart and VacationEnd describe the contiguous
// vacation block the original date sits in. Populated only when Kind
// == "vacation". Span boundaries are the first/last vacation day in
// the block (excludes the weekends that pad it).
VacationName string `json:"vacationName,omitempty"`
VacationStart string `json:"vacationStart,omitempty"`
VacationEnd string `json:"vacationEnd,omitempty"`
// OriginalWeekday is the English weekday name of the original date —
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
// can localise it.
OriginalWeekday string `json:"originalWeekday,omitempty"`
}
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
type HolidayDTO struct {
Date string `json:"date"`
Name string `json:"name"`
IsVacation bool `json:"isVacation,omitempty"`
IsClosure bool `json:"isClosure,omitempty"`
}
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
// explanation. Reason is nil when wasAdjusted is false.
//
// The walk semantics (forward, day-by-day, 60-iter bound) are identical to
// AdjustForNonWorkingDays — only an additional bookkeeping pass collects the
// holidays hit and decides the dominant Kind. Do not shrink the 60-iter
// bound; see AdjustForNonWorkingDays for the t-paliad-086 PR-2 history.
func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool, reason *AdjustmentReason) {
original = date
adjusted = date
var holidaysHit []HolidayDTO
seen := map[string]bool{}
var sawWeekend, sawVacation, sawPublicHoliday bool
var vacationName string
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted); i++ {
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
sawWeekend = true
}
if h := s.IsHoliday(adjusted); h != nil {
if h.IsVacation {
sawVacation = true
if vacationName == "" {
vacationName = h.Name
}
} else {
sawPublicHoliday = true
}
key := h.Date.Format("2006-01-02") + "|" + h.Name
if !seen[key] {
holidaysHit = append(holidaysHit, HolidayDTO{
Date: h.Date.Format("2006-01-02"),
Name: h.Name,
IsVacation: h.IsVacation,
IsClosure: h.IsClosure,
})
seen[key] = true
}
}
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
if !wasAdjusted {
return adjusted, original, false, nil
}
r := &AdjustmentReason{Holidays: holidaysHit}
switch {
case sawVacation:
r.Kind = "vacation"
r.VacationName = vacationName
if vs, ve, ok := s.findVacationBlock(original); ok {
r.VacationStart = vs.Format("2006-01-02")
r.VacationEnd = ve.Format("2006-01-02")
}
case sawPublicHoliday:
r.Kind = "public_holiday"
default:
r.Kind = "weekend"
}
if sawWeekend && r.Kind == "weekend" {
r.OriginalWeekday = original.Weekday().String()
}
return adjusted, original, true, r
}
// findVacationBlock locates the contiguous vacation block that `date` lies
// in by scanning outward through non-working days. Returns the earliest and
// latest IsVacation entries reached before hitting a working day. Weekends
// inside the run are traversed (non-working) but don't extend the reported
// span — start/end are always real vacation entries.
func (s *HolidayService) findVacationBlock(date time.Time) (start, end time.Time, ok bool) {
var firstVac, lastVac time.Time
cur := date
for i := 0; i < 60; i++ {
if !s.IsNonWorkingDay(cur) {
break
}
if h := s.IsHoliday(cur); h != nil && h.IsVacation {
firstVac = cur
}
cur = cur.AddDate(0, 0, -1)
}
cur = date.AddDate(0, 0, 1)
for i := 0; i < 60; i++ {
if !s.IsNonWorkingDay(cur) {
break
}
if h := s.IsHoliday(cur); h != nil && h.IsVacation {
lastVac = cur
}
cur = cur.AddDate(0, 0, 1)
}
if firstVac.IsZero() && lastVac.IsZero() {
return time.Time{}, time.Time{}, false
}
if firstVac.IsZero() {
firstVac = lastVac
}
if lastVac.IsZero() {
lastVac = firstVac
}
return firstVac, lastVac, true
}
// germanFederalHolidays returns the 11 holidays observed in all 16 German Länder.

View File

@@ -94,6 +94,121 @@ func TestAdjustForNonWorkingDays_NoDB(t *testing.T) {
}
}
// AdjustForNonWorkingDaysWithReason classifies the dominant cause and (for
// the vacation case) reports the contiguous block boundaries — feeds
// /tools/fristenrechner's "Verschoben wegen UPC-Sommerferien (27.7.28.8.)"
// label, replacing the generic "Wochenende/Feiertag" string. See t-paliad-119.
func TestAdjustForNonWorkingDaysWithReason_Weekend(t *testing.T) {
s := NewHolidayService(nil) // German federal holidays only — sufficient for weekend / public-holiday cases.
// Saturday 2026-01-03 → Monday 2026-01-05, Kind = "weekend".
in := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
if !wasAdj || reason == nil {
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "weekend" {
t.Errorf("Kind: got %q, want weekend", reason.Kind)
}
if reason.OriginalWeekday != "Saturday" {
t.Errorf("OriginalWeekday: got %q, want Saturday", reason.OriginalWeekday)
}
if len(reason.Holidays) != 0 {
t.Errorf("Holidays should be empty for weekend-only, got %d", len(reason.Holidays))
}
want := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
if !adj.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adj, want)
}
}
func TestAdjustForNonWorkingDaysWithReason_PublicHoliday(t *testing.T) {
s := NewHolidayService(nil)
// Karfreitag 2025 (Fri 2025-04-18) → Tue 2025-04-22 (skips Sa/Su +
// Ostersonntag + Ostermontag). All three are public_holidays in the
// hardcoded set; Holidays should contain at least Karfreitag and
// Ostermontag (Ostersonntag too — it is a federal holiday entry).
in := time.Date(2025, 4, 18, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
if !wasAdj || reason == nil {
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "public_holiday" {
t.Errorf("Kind: got %q, want public_holiday", reason.Kind)
}
want := time.Date(2025, 4, 22, 0, 0, 0, 0, time.UTC)
if !adj.Equal(want) {
t.Errorf("adjusted: got %s, want %s", adj, want)
}
names := map[string]bool{}
for _, h := range reason.Holidays {
names[h.Name] = true
}
if !names["Karfreitag"] {
t.Errorf("Holidays should contain Karfreitag, got %v", names)
}
if !names["Ostermontag"] {
t.Errorf("Holidays should contain Ostermontag, got %v", names)
}
if reason.VacationName != "" || reason.VacationStart != "" {
t.Errorf("public_holiday reason should not set vacation fields, got %+v", reason)
}
}
func TestAdjustForNonWorkingDaysWithReason_Vacation(t *testing.T) {
// Inject UPC summer vacation 2026 directly into the cache (no DB) so
// the test reflects the production seed without needing a live DB.
s := NewHolidayService(nil)
holidays := make([]Holiday, 0, 11+25)
holidays = append(holidays, germanFederalHolidays(2026)...)
for d := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC); !d.After(time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC)); d = d.AddDate(0, 0, 1) {
if wd := d.Weekday(); wd == time.Saturday || wd == time.Sunday {
continue // matches migration 010 — weekdays only
}
holidays = append(holidays, Holiday{Date: d, Name: "UPC Summer Vacation", IsVacation: true})
}
entry := &yearEntry{holidays: holidays}
entry.once.Do(func() {}) // mark loaded so loadYear is not invoked.
s.cache.Store(2026, entry)
// Tue 2026-08-04 (m's reproduction): inside UPC summer vacation →
// adjusted to Mon 2026-08-31, with vacation span 27.7.28.8.
in := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
if !wasAdj || reason == nil {
t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
}
if reason.Kind != "vacation" {
t.Errorf("Kind: got %q, want vacation", reason.Kind)
}
if reason.VacationName != "UPC Summer Vacation" {
t.Errorf("VacationName: got %q, want UPC Summer Vacation", reason.VacationName)
}
if reason.VacationStart != "2026-07-27" {
t.Errorf("VacationStart: got %q, want 2026-07-27", reason.VacationStart)
}
if reason.VacationEnd != "2026-08-28" {
t.Errorf("VacationEnd: got %q, want 2026-08-28", reason.VacationEnd)
}
wantAdj := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
if !adj.Equal(wantAdj) {
t.Errorf("adjusted: got %s, want %s", adj, wantAdj)
}
}
func TestAdjustForNonWorkingDaysWithReason_NoShift(t *testing.T) {
s := NewHolidayService(nil)
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) // regular Tuesday
adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in)
if wasAdj || reason != nil {
t.Errorf("regular Tuesday should not adjust; got wasAdj=%v reason=%v", wasAdj, reason)
}
if !adj.Equal(in) {
t.Errorf("adjusted: got %s, want %s", adj, in)
}
}
// Verifies the cache is concurrency-safe (audit §1.6 fix). Run with -race.
func TestLoadHolidaysForYear_ConcurrentReads(t *testing.T) {
s := NewHolidayService(nil)