Files
paliad/pkg/litigationplanner/embedded/upc/holidays.go
mAi 39353d49ed fix(litigationplanner): UPC vacations no longer block deadlines (align with paliad t-paliad-121)
youpc.org/deadlines was rolling a deadline "from 2027-01-02 (UPC Winter
Vacation)" — i.e. across the UPC judicial vacation as if it were a
public holiday. Paliad-side t-paliad-121 already decided vacations are
informational only (the Court keeps running through them, RoP / UPC AC
decision-on-judicial-vacation 2023-05-26), and `HolidayService.Is
NonWorkingDay` in `internal/services/holidays.go` is correct. The
embedded snapshot consumed by youpc.org via Go-module replace had
drifted: `pkg/litigationplanner/embedded/upc/holidays.go:74` blocked on
both `isClosure()` AND `isVacation()`.

This commit aligns the embedded calendar with the paliad-side semantics
and ships a fresh holiday set so the existing 2026/2027 fix actually
takes effect downstream.

Code changes (`holidays.go`):

- `IsNonWorkingDay`: drop the `|| h.isVacation()` branch — only weekends
  and `isClosure()` rows trigger the roll. Godoc rewritten to mirror
  the paliad-side rationale (Court keeps operating, RoP cites,
  vacation rows kept for informational labels).
- `isClosure()`: accept both `"public_holiday"` and `"closure"`. Live
  paliad DB rows use the `public_holiday` value; the placeholder
  snapshot shipped with the original Slice C used `closure` as a
  hand-crafted synonym. Reconciles with
  `internal/services/holidays.go:132` which already does the same
  union. Required to make the regenerated JSON (full of
  `public_holiday`) keep blocking DE national holidays after the
  regeneration in this commit.
- Type-level godoc updated: `SnapshotHolidayCalendar` now documents
  vacation-is-informational, and the `AdjustForNonWorkingDaysWithReason`
  precedence note explains that `vacation` kind only fires when a
  vacation row overlaps a weekend or closure that's already doing the
  rolling.

Data refresh (`holidays.json`):

- Regenerated from paliad prod (postgres @ 100.99.98.201:11833,
  paliad schema). 55 rows for 2026 + 2027: 22 DE public_holiday +
  33 UPC vacation (25 Summer Vacation Jul 27–Aug 28, 8 Winter
  Vacation Dec 24/28–31 + Jan 4–6). The previous placeholder shipped
  only 5 rows (3 Sommerpause + Neujahr + Tag der Arbeit, no Winter
  Vacation at all) — which is why a date landing in late Dec / early
  Jan landed inside an unmodeled gap on the consumer side.
- `meta.json` bumped: version → `2026-05-27-1-holidays-only`,
  `holiday_count` 5 → 55, `source_db_label` flags that only
  holidays.json was refreshed (see friction note below).

Regression test (`snapshot_test.go::TestSnapshotHolidayCalendar`):

- 2026-08-04 (Tue, UPC Summer Vacation) — `IsNonWorkingDay` must be
  false; `AdjustForNonWorkingDays` must NOT mutate the date.
- 2027-01-02 (Sat, m's flagged scenario) — must roll forward through
  Sat/Sun, then STOP on Mon 2027-01-04 (UPC Winter Vacation, no longer
  blocking). Pre-fix this rolled all the way to Thu 2027-01-07.

Cross-repo: youpc.org imports `pkg/litigationplanner` via Go-module
replace; the regenerated snapshot ships on its next rebuild. No
separate youpc.org commit needed — paliad is the source of truth.

Friction note: `cmd/gen-upc-snapshot/main.go` itself is incompatible
with the current paliad schema. Migration 140 (`140_drop_deadline_rules`)
dropped `paliad.deadline_rules`, but the generator still SELECTs from
it (main.go ~L162). Running the tool against prod fails on the rules
step. I bypassed the broken path and generated `holidays.json` directly
from the DB via psql + jq (same JSON shape that `EmbeddedHoliday`
expects, nulls filtered for `omitempty`). The other snapshot files
(rules.json, proceeding_types.json, trigger_events.json, courts.json)
remain at their pre-existing placeholder state — re-flagged in
meta.json's `source_db_label`. Refitting the generator for the post-
mig-140 schema is a separate task.

go vet + go test ./... clean (256+ Go tests pass, including the new
regression cases).
2026-05-27 15:04:05 +02:00

238 lines
7.9 KiB
Go

package upc
import (
"time"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotHoliday is the embedded holiday row shape. Mirrors
// paliad.holidays + the generator's output. Country and Regime are
// optional pointers — at least one of them is non-empty on every
// row (matches paliad's CHECK).
type SnapshotHoliday struct {
Date string `json:"date"` // YYYY-MM-DD
Name string `json:"name"`
Country *string `json:"country,omitempty"`
Regime *string `json:"regime,omitempty"`
State *string `json:"state,omitempty"`
HolidayType string `json:"holiday_type"`
}
func (h SnapshotHoliday) appliesTo(country, regime string) bool {
if h.Country != nil && country != "" && *h.Country == country {
return true
}
if h.Regime != nil && regime != "" && *h.Regime == regime {
return true
}
return false
}
func (h SnapshotHoliday) isVacation() bool { return h.HolidayType == "vacation" }
// isClosure accepts both "public_holiday" and "closure" so the
// embedded calendar matches paliad's HolidayService.IsClosure
// reconciliation (internal/services/holidays.go ~L132). Live DB rows
// use "public_holiday"; "closure" is kept as a legacy synonym so old
// hand-crafted snapshots still parse correctly.
func (h SnapshotHoliday) isClosure() bool {
return h.HolidayType == "public_holiday" || h.HolidayType == "closure"
}
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
// holiday slice. The semantics mirror paliad's HolidayService:
//
// - IsNonWorkingDay = weekend OR a closure row matching the
// (country, regime) pair. "Vacation" rows are informational only
// and do not block — see t-paliad-121 / IsNonWorkingDay godoc.
// - AdjustForNonWorkingDays = walk forward day-by-day until
// IsNonWorkingDay returns false (bounded at 60 iters)
// - AdjustForNonWorkingDaysBackward = same but stepping -1 day
// - AdjustForNonWorkingDaysWithReason = forward walk + structured
// reason payload (vacation > public_holiday > weekend) — vacation
// kind fires only when a vacation row overlaps a weekend or
// closure that is doing the rolling.
type SnapshotHolidayCalendar struct {
byDate map[string][]SnapshotHoliday // keyed by YYYY-MM-DD
}
// NewHolidayCalendar parses the embedded holidays.json and returns a
// ready-to-use calendar.
func NewHolidayCalendar() (*SnapshotHolidayCalendar, error) {
var holidays []SnapshotHoliday
if err := readJSON("holidays.json", &holidays); err != nil {
return nil, err
}
cal := &SnapshotHolidayCalendar{byDate: make(map[string][]SnapshotHoliday, len(holidays))}
for _, h := range holidays {
cal.byDate[h.Date] = append(cal.byDate[h.Date], h)
}
return cal, nil
}
// IsNonWorkingDay returns true on weekends or closure-type holidays
// applicable to the given (country, regime).
//
// "Vacation" entries (today: UPC summer + winter judicial vacations
// per the UPC AC decision on judicial vacations 2023-05-26) are
// deliberately excluded — the Court continues to operate during them
// and they do not extend procedural deadlines (RoP / AC decision-on-
// judicial-vacation). They stay in holidays.json as informational
// metadata so callers can still surface "this date overlaps with UPC
// vacation" if they want. Mirrors HolidayService.IsNonWorkingDay in
// internal/services — see t-paliad-121 for the policy decision and
// t-paliad-332 for the snapshot-side alignment.
func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regime string) bool {
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
return true
}
key := date.Format("2006-01-02")
for _, h := range c.byDate[key] {
if !h.appliesTo(country, regime) {
continue
}
if h.isClosure() {
return true
}
}
return false
}
func (c *SnapshotHolidayCalendar) holidayMatch(date time.Time, country, regime string) *SnapshotHoliday {
key := date.Format("2006-01-02")
for _, h := range c.byDate[key] {
if !h.appliesTo(country, regime) {
continue
}
hh := h
return &hh
}
return nil
}
// AdjustForNonWorkingDays walks forward until the date lands on a
// working day. Bound = 60 iters (same as paliad — generous safety
// margin past any vacation run).
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDaysBackward walks backward until the date lands
// on a working day. Same bound.
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, -1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDaysWithReason is the structured-explanation
// counterpart to AdjustForNonWorkingDays. Reason kind precedence
// (longest cause wins): vacation > public_holiday > weekend. Reason
// is nil when wasAdjusted is false.
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *lp.AdjustmentReason) {
original = date
adjusted = date
var holidaysHit []lp.HolidayDTO
seen := map[string]bool{}
var sawWeekend, sawVacation, sawPublicHoliday bool
var vacationName string
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
sawWeekend = true
}
if h := c.holidayMatch(adjusted, country, regime); h != nil {
if h.isVacation() {
sawVacation = true
if vacationName == "" {
vacationName = h.Name
}
} else if h.isClosure() {
sawPublicHoliday = true
}
key := h.Date + "|" + h.Name
if !seen[key] {
holidaysHit = append(holidaysHit, lp.HolidayDTO{
Date: h.Date,
Name: h.Name,
IsVacation: h.isVacation(),
IsClosure: h.isClosure(),
})
seen[key] = true
}
}
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
if !wasAdjusted {
return adjusted, original, false, nil
}
r := &lp.AdjustmentReason{Holidays: holidaysHit}
switch {
case sawVacation:
r.Kind = "vacation"
r.VacationName = vacationName
if vs, ve, ok := c.findVacationBlock(original, country, regime); 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 scans outward from date through non-working days
// to locate the first/last IsVacation entries. Weekends inside the
// run are traversed but don't extend the reported span — start/end
// are always real vacation entries.
func (c *SnapshotHolidayCalendar) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
cur := date
for i := 0; i < 60; i++ {
if !c.IsNonWorkingDay(cur, country, regime) {
break
}
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
start = cur
ok = true
break
}
cur = cur.AddDate(0, 0, -1)
}
if !ok {
return
}
cur = date
for i := 0; i < 60; i++ {
if !c.IsNonWorkingDay(cur, country, regime) {
break
}
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
end = cur
}
cur = cur.AddDate(0, 0, 1)
}
return start, end, true
}
// Compile-time assertion that SnapshotHolidayCalendar satisfies
// lp.HolidayCalendar.
var _ lp.HolidayCalendar = (*SnapshotHolidayCalendar)(nil)