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"