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:
@@ -4,6 +4,22 @@
|
|||||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||||
import { initSidebar } from "./sidebar";
|
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 {
|
interface CalculatedDeadline {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,6 +32,7 @@ interface CalculatedDeadline {
|
|||||||
dueDate: string;
|
dueDate: string;
|
||||||
originalDate: string;
|
originalDate: string;
|
||||||
wasAdjusted: boolean;
|
wasAdjusted: boolean;
|
||||||
|
adjustmentReason?: AdjustmentReason;
|
||||||
isRootEvent: boolean;
|
isRootEvent: boolean;
|
||||||
isCourtSet: boolean;
|
isCourtSet: boolean;
|
||||||
}
|
}
|
||||||
@@ -71,6 +88,68 @@ function partyBadge(party: string): string {
|
|||||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
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 = "";
|
let selectedType = "";
|
||||||
|
|
||||||
function showStep(n: number) {
|
function showStep(n: number) {
|
||||||
@@ -331,7 +410,7 @@ function renderTimeline(data: DeadlineResponse) {
|
|||||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||||
|
|
||||||
const adjustedNote = dl.wasAdjusted
|
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
|
const ruleRef = dl.ruleRef
|
||||||
|
|||||||
@@ -261,6 +261,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"deadlines.event.timing.after": "nach",
|
"deadlines.event.timing.after": "nach",
|
||||||
"deadlines.adjusted": "Verschoben",
|
"deadlines.adjusted": "Verschoben",
|
||||||
"deadlines.adjusted.reason": "Wochenende/Feiertag",
|
"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
|
||||||
"downloads.title": "Downloads \u2014 Paliad",
|
"downloads.title": "Downloads \u2014 Paliad",
|
||||||
@@ -1736,6 +1741,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"deadlines.court.set": "set by court",
|
"deadlines.court.set": "set by court",
|
||||||
"deadlines.adjusted": "Adjusted",
|
"deadlines.adjusted": "Adjusted",
|
||||||
"deadlines.adjusted.reason": "weekend/holiday",
|
"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)
|
// Trigger-event mode (PR-2 — youpc-parity)
|
||||||
"deadlines.mode.procedure": "Course of proceedings",
|
"deadlines.mode.procedure": "Course of proceedings",
|
||||||
"deadlines.mode.event": "What comes after…",
|
"deadlines.mode.event": "What comes after…",
|
||||||
|
|||||||
@@ -533,7 +533,12 @@ export type I18nKey =
|
|||||||
| "dashboard.when.tomorrow"
|
| "dashboard.when.tomorrow"
|
||||||
| "deadlines.action.reopen"
|
| "deadlines.action.reopen"
|
||||||
| "deadlines.adjusted"
|
| "deadlines.adjusted"
|
||||||
|
| "deadlines.adjusted.holiday"
|
||||||
| "deadlines.adjusted.reason"
|
| "deadlines.adjusted.reason"
|
||||||
|
| "deadlines.adjusted.vacation"
|
||||||
|
| "deadlines.adjusted.weekend"
|
||||||
|
| "deadlines.adjusted.weekend.saturday"
|
||||||
|
| "deadlines.adjusted.weekend.sunday"
|
||||||
| "deadlines.calculate"
|
| "deadlines.calculate"
|
||||||
| "deadlines.col.akte"
|
| "deadlines.col.akte"
|
||||||
| "deadlines.col.due"
|
| "deadlines.col.due"
|
||||||
|
|||||||
@@ -34,20 +34,21 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi
|
|||||||
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
||||||
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||||
type UIDeadline struct {
|
type UIDeadline struct {
|
||||||
RuleID string `json:"ruleId,omitempty"`
|
RuleID string `json:"ruleId,omitempty"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
NameEN string `json:"nameEN"`
|
NameEN string `json:"nameEN"`
|
||||||
Party string `json:"party"`
|
Party string `json:"party"`
|
||||||
IsMandatory bool `json:"isMandatory"`
|
IsMandatory bool `json:"isMandatory"`
|
||||||
RuleRef string `json:"ruleRef"`
|
RuleRef string `json:"ruleRef"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
NotesEN string `json:"notesEN,omitempty"`
|
NotesEN string `json:"notesEN,omitempty"`
|
||||||
DueDate string `json:"dueDate"`
|
DueDate string `json:"dueDate"`
|
||||||
OriginalDate string `json:"originalDate"`
|
OriginalDate string `json:"originalDate"`
|
||||||
WasAdjusted bool `json:"wasAdjusted"`
|
WasAdjusted bool `json:"wasAdjusted"`
|
||||||
IsRootEvent bool `json:"isRootEvent"`
|
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||||
IsCourtSet bool `json:"isCourtSet"`
|
IsRootEvent bool `json:"isRootEvent"`
|
||||||
|
IsCourtSet bool `json:"isCourtSet"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
// 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)
|
endDate := addDuration(baseDate, durationValue, durationUnit)
|
||||||
origDate := endDate
|
origDate := endDate
|
||||||
adjusted, _, wasAdj := s.holidays.AdjustForNonWorkingDays(endDate)
|
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
|
||||||
|
|
||||||
d.OriginalDate = origDate.Format("2006-01-02")
|
d.OriginalDate = origDate.Format("2006-01-02")
|
||||||
d.DueDate = adjusted.Format("2006-01-02")
|
d.DueDate = adjusted.Format("2006-01-02")
|
||||||
d.WasAdjusted = wasAdj
|
d.WasAdjusted = wasAdj
|
||||||
|
d.AdjustmentReason = reason
|
||||||
if r.Code != nil {
|
if r.Code != nil {
|
||||||
computed[*r.Code] = adjusted
|
computed[*r.Code] = adjusted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// — 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).
|
// 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) {
|
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
|
original = date
|
||||||
adjusted = 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++ {
|
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)
|
adjusted = adjusted.AddDate(0, 0, 1)
|
||||||
wasAdjusted = true
|
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.
|
// germanFederalHolidays returns the 11 holidays observed in all 16 German Länder.
|
||||||
|
|||||||
@@ -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.
|
// Verifies the cache is concurrency-safe (audit §1.6 fix). Run with -race.
|
||||||
func TestLoadHolidaysForYear_ConcurrentReads(t *testing.T) {
|
func TestLoadHolidaysForYear_ConcurrentReads(t *testing.T) {
|
||||||
s := NewHolidayService(nil)
|
s := NewHolidayService(nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user