Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.
Generator (cmd/gen-upc-snapshot):
- Reads paliad's live DB (DATABASE_URL), applies pending migrations
to match schema HEAD, SELECTs the UPC subset
(proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
deadline_rules WHERE lifecycle_state='published' AND is_active=true
on those proceedings, referenced trigger_events, DE+UPC holidays,
UPC courts).
- Writes pretty-printed JSON to
pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
trigger_events, holidays, courts, meta}.json.
- Idempotent — same DB state → same output (modulo
meta.generated_at + auto-versioned suffix).
- Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
- Operator runbook in cmd/gen-upc-snapshot/README.md.
Embedded subpackage (pkg/litigationplanner/embedded/upc/):
- embed.go — //go:embed *.json + LoadMeta()
- snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
/ LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
- holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
(IsNonWorkingDay / Adjust* with structured AdjustmentReason).
- courts.go — SnapshotCourtRegistry implementing lp.CourtRegistry.
- Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
interface drift.
Wire-up for consumers:
cat, _ := upc.NewCatalog()
hc, _ := upc.NewHolidayCalendar()
cr, _ := upc.NewCourtRegistry()
timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
lp.CalcOptions{}, cat, hc, cr)
Tests (snapshot_test.go, all DB-free):
- meta parses cleanly, non-zero counts
- LoadProceeding(upc.inf.cfi) returns expected proc + rules
- LoadProceeding(unknown) returns ErrUnknownProceedingType
- LookupEvents(Jurisdiction:UPC, all-following) covers corpus
- LookupEvents(party=defendant, next) scopes anchors correctly
- engine end-to-end via lp.Calculate against the embedded snapshot
- holiday calendar (weekends, DE closures, UPC vacation block)
- court registry (empty courtID fallback, known + unknown court)
Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.
Makefile target:
make snapshot-upc — wraps the generator + reruns the snapshot tests
Design (§19 of docs/design-litigation-planner-2026-05-26.md):
- Embedding format: go:embed JSON (diff-friendly, no compile coupling)
- Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
- Versioning: meta.json carries semver + generated_at + paliad_commit
- Regeneration: manual via Make target or `go generate`; no CI cron in v1
- Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
diff tooling
Acceptance:
- go build clean, go test all green (incl. 6 new tests in
pkg/litigationplanner/embedded/upc, all DB-free)
- SnapshotCatalog passes the compile-time lp.Catalog assertion
- Generator binary builds + runs (Idempotence verified by re-running
against the same source data)
217 lines
6.7 KiB
Go
217 lines
6.7 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" }
|
|
func (h SnapshotHoliday) isClosure() bool { return h.HolidayType == "closure" }
|
|
|
|
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
|
|
// holiday slice. The semantics mirror paliad's HolidayService:
|
|
//
|
|
// - IsNonWorkingDay = weekend OR a closure/vacation row matching
|
|
// the (country, regime) pair
|
|
// - 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)
|
|
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/vacation
|
|
// holidays applicable to the given country/regime.
|
|
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() || h.isVacation() {
|
|
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)
|