Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.
Package contents (~1850 LoC):
- doc.go package docstring + reuse manifesto
- types.go Rule, ProceedingType, NullableJSON, AdjustmentReason,
HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
TimelineEntry, RuleCalculation*, FristenrechnerType,
ProjectHint, sentinel errors
- catalog.go Catalog interface (proceeding + rule lookups)
- holidays.go HolidayCalendar interface
- courts.go CourtRegistry interface + DefaultsForJurisdiction +
country/regime constants
- expr.go EvalConditionExpr + HasConditionExpr +
ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go MapLitigationToFristenrechner + code constants
(CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go Calculate + CalculateRule + the trigger-event
branch + applyRuleOverrides (the big move)
paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
(thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
become type aliases to litigationplanner.* — every sqlx scan and
every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
+ BuildLegalSourceURL replaced with delegating wrappers to lp.
Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.
Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.
Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.
Refs: docs/design-litigation-planner-2026-05-26.md
152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
package litigationplanner
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// SortDeadlinesByDurationWithinTriggerGroup is the public form of
|
|
// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's
|
|
// test suite (which historically reached the helper directly) can
|
|
// keep invoking it via a tiny wrapper.
|
|
func SortDeadlinesByDurationWithinTriggerGroup(
|
|
deadlines []TimelineEntry,
|
|
ruleByID map[uuid.UUID]Rule,
|
|
) {
|
|
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
|
}
|
|
|
|
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
|
|
// deadlines whose underlying rule shares the same trigger group
|
|
// (parent_id + trigger_event_id) and reorders each run in place by
|
|
// duration ascending. Different trigger groups keep their original
|
|
// proceeding-sequence position — the walk only ever permutes adjacent
|
|
// same-group rows.
|
|
//
|
|
// Sort key (within a run):
|
|
// 1. Conditional / court-set rows (no concrete date in the duration
|
|
// ladder) sort LAST, tiebroken by submission_code.
|
|
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
|
|
// 3. duration_value ASC
|
|
// 4. submission_code ASC (deterministic tiebreak)
|
|
//
|
|
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
|
|
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
|
|
// order instead of likely-sequence order. (t-paliad-296)
|
|
func sortDeadlinesByDurationWithinTriggerGroup(
|
|
deadlines []TimelineEntry,
|
|
ruleByID map[uuid.UUID]Rule,
|
|
) {
|
|
if len(deadlines) < 2 {
|
|
return
|
|
}
|
|
n := len(deadlines)
|
|
i := 0
|
|
for i < n {
|
|
gid := triggerGroupKey(deadlines[i], ruleByID)
|
|
j := i + 1
|
|
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
|
|
j++
|
|
}
|
|
// Root rules (no parent and no trigger_event) get gid="" and
|
|
// would otherwise collapse into one big run. Skip the sort for
|
|
// the "root" pseudo-group — each root rule represents its own
|
|
// anchor (SoC, oral hearing, decision …) and the proceeding-
|
|
// sequence order between them must be preserved.
|
|
if j-i > 1 && gid != "" {
|
|
chunk := deadlines[i:j]
|
|
sort.SliceStable(chunk, func(a, b int) bool {
|
|
return durationLessForSort(chunk[a], chunk[b], ruleByID)
|
|
})
|
|
}
|
|
i = j
|
|
}
|
|
}
|
|
|
|
// triggerGroupKey returns a string key identifying which trigger group
|
|
// a deadline belongs to. Same key = same group = candidates for sort.
|
|
// Empty string means "root" (no parent, no trigger_event) — used as a
|
|
// sentinel by the caller to skip sorting roots against each other.
|
|
func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string {
|
|
rid, err := uuid.Parse(d.RuleID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
r, ok := ruleByID[rid]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if r.ParentID != nil {
|
|
return "p:" + r.ParentID.String()
|
|
}
|
|
if r.TriggerEventID != nil {
|
|
return fmt.Sprintf("t:%d", *r.TriggerEventID)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// durationLessForSort compares two deadlines for the duration-ascending
|
|
// sort. Court-set / conditional rows (no concrete date) sort LAST
|
|
// regardless of duration — they don't fit the duration ladder.
|
|
func durationLessForSort(
|
|
a, b TimelineEntry,
|
|
ruleByID map[uuid.UUID]Rule,
|
|
) bool {
|
|
aLast := a.IsCourtSet || a.IsConditional
|
|
bLast := b.IsCourtSet || b.IsConditional
|
|
if aLast != bLast {
|
|
return !aLast
|
|
}
|
|
if aLast && bLast {
|
|
return a.Code < b.Code
|
|
}
|
|
|
|
ra := lookupRuleFromDeadline(a, ruleByID)
|
|
rb := lookupRuleFromDeadline(b, ruleByID)
|
|
|
|
wa := durationUnitWeight(ra.DurationUnit)
|
|
wb := durationUnitWeight(rb.DurationUnit)
|
|
if wa != wb {
|
|
return wa < wb
|
|
}
|
|
if ra.DurationValue != rb.DurationValue {
|
|
return ra.DurationValue < rb.DurationValue
|
|
}
|
|
return a.Code < b.Code
|
|
}
|
|
|
|
func lookupRuleFromDeadline(
|
|
d TimelineEntry,
|
|
ruleByID map[uuid.UUID]Rule,
|
|
) Rule {
|
|
if d.RuleID == "" {
|
|
return Rule{}
|
|
}
|
|
rid, err := uuid.Parse(d.RuleID)
|
|
if err != nil {
|
|
return Rule{}
|
|
}
|
|
return ruleByID[rid]
|
|
}
|
|
|
|
// durationUnitWeight maps a duration unit to its sort weight so the
|
|
// trigger-group sort can order shorter durations first. days and
|
|
// working_days share weight 0 (both are sub-week granularities);
|
|
// unknown units sort to the end so they're visible as a tail rather
|
|
// than silently winning.
|
|
func durationUnitWeight(unit string) int {
|
|
switch unit {
|
|
case "days", "working_days":
|
|
return 0
|
|
case "weeks":
|
|
return 1
|
|
case "months":
|
|
return 2
|
|
case "years":
|
|
return 3
|
|
}
|
|
return 4
|
|
}
|