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 }