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).
238 lines
7.9 KiB
Go
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)
|