package services // ProjectionService composes the SmartTimeline read view for a project — // the merged stream of past actuals (deadlines + appointments + opted-in // project_events) plus future projections from the fristenrechner. // // Slice 1 (t-paliad-171) returned only actuals. Slice 2 (t-paliad-173) // adds: // // - Future-projection rows (Kind="projected") via FristenrechnerService. // - 7-event lookahead cap with ?lookahead=N override (1..50). // - Predicted-overdue rows (past projected dates without an anchor) // bypass the cap and surface as Status="predicted_overdue". // - Dependency annotations (DependsOnRuleCode/Date/Name) on every row // derived from a deadline_rule with a parent_id. // - Anchor + skip write paths (RecordAnchor, RecordRuleSkipped). // // Slice 4 (t-paliad-175) adds parent-node lane aggregation (§5): // // - levelPolicy(projectType) returns the (kinds, statuses, lane_axis) // triple per level — Case = full detail + CCR track; Patent = lanes // per child case (deadlines + milestones, done+open+overdue); // Litigation = lanes per child patent (milestones, done); Client = // lanes per child litigation (milestones, done; opt-in via toggle). // - Lanes []LaneInfo on the response envelope, LaneID on every event // row — frontend buckets by lane for parallel-column rendering. // - metadata.bubble_up=true on paliad.project_events overrides the // kind/status filter at higher levels so structural milestones // (counterclaim_created, third_party_intervention, scope_change, // opt-in custom_milestone) survive the aggregation cull. // // See docs/design-smart-timeline-2026-05-08.md §5 + §6 + §9 + §10 // and m/paliad#31 for the layered requirements. import ( "context" "database/sql" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/models" ) // DefaultLookaheadCap is the number of future projected rows surfaced by // default. m/paliad#31: fixed across proceeding types in v1; "Mehr // anzeigen" / "Weniger anzeigen" buttons control client-side override // via the ?lookahead=N query parameter. const DefaultLookaheadCap = 7 // ErrCyclicSpawn signals that the cross-proceeding spawn graph has a // cycle reachable from a project's source proceeding (design §6.3, // Slice 7 t-paliad-188). Surfaced when the visited-set DFS in // expandCrossProceedingSpawns hits a proceeding_type_id already in the // chain. ProjectionService.computeProjections degrades to "no spawned // rows" rather than failing the whole SmartTimeline render. var ErrCyclicSpawn = errors.New("cyclic cross-proceeding spawn") // maxSpawnDepth caps recursive spawn expansion as a safety belt in // addition to the visited-set guard. No legitimate spawn graph today // reaches depth 4 (the live corpus has 6 spawn rules across 3 source // proceedings → AMD / APP / CCR — each one-hop). Bump if real-world // chains demand it; until then the cap is a backstop. const maxSpawnDepth = 4 // MaxLookaheadCap caps the ?lookahead override so a misbehaving client // can't request thousands of projected rows. const MaxLookaheadCap = 50 // TimelineEvent is one row in the SmartTimeline merge. The struct is // the wire contract of GET /api/projects/{id}/timeline; new slices // extend it additively (new fields, never rename / repurpose). // // Provenance fields (DeadlineID, AppointmentID, ProjectEventID) — exactly // one is non-nil for actual rows. All three are nil for Kind="projected" // rows. Frontend deep-links via the populated id; projected rows expose // DeadlineRuleID for the click-to-anchor affordance. type TimelineEvent struct { Kind string `json:"kind"` // "deadline" | "appointment" | "milestone" | "projected" Status string `json:"status"` // "done" | "open" | "overdue" | "court_set" | "predicted" | "predicted_overdue" | "off_script" Track string `json:"track"` // "parent" | "counterclaim" | "child:" | "off_script" // Date is nil for undated rows (court-set decisions, counterclaim-pending // milestones). Undated rows sort to the end. Date *time.Time `json:"date,omitempty"` Title string `json:"title"` Description string `json:"description,omitempty"` RuleCode string `json:"rule_code,omitempty"` DeadlineID *uuid.UUID `json:"deadline_id,omitempty"` AppointmentID *uuid.UUID `json:"appointment_id,omitempty"` ProjectEventID *uuid.UUID `json:"project_event_id,omitempty"` // Click-to-anchor handle (Slice 2). Populated on Kind="projected" // rows AND on actuals (Kind="deadline"/"appointment") that derive // from a deadline_rule, so the frontend can show the rule chip on // both alike. Party drives the side-of-the-table colour class. DeadlineRuleID *uuid.UUID `json:"deadline_rule_id,omitempty"` DeadlineRuleParty string `json:"deadline_rule_party,omitempty"` // Reserved for Slice 3 (counterclaim sub-projects); populated when // the row belongs to a child project rendered alongside the parent. SubProjectID *uuid.UUID `json:"sub_project_id,omitempty"` SubProjectTitle string `json:"sub_project_title,omitempty"` // Dependency annotations (m/paliad#31 layer 2). Populated on rows // derived from a deadline_rule with a non-NULL parent_id. The // frontend renders "Folgt aus: ()" footer plus the // "[Pfad anzeigen]" button when DependsOnRuleCode != "". // // DependsOnDate is nil when the parent has no anchored actual AND // no projection (e.g. court-set parent that hasn't fired yet) — // the UI renders "Datum offen" in that case. DependsOnRuleCode string `json:"depends_on_rule_code,omitempty"` DependsOnDate *time.Time `json:"depends_on_date,omitempty"` DependsOnRuleName string `json:"depends_on_rule_name,omitempty"` // LaneID buckets the row into a parallel column at parent-node levels // (t-paliad-175 SmartTimeline Slice 4). At Case level, LaneID mirrors // Track ("self" for the parent track, "counterclaim:" for CCR // children, "parent_context:" for the CCR child's parent context). // At Patent / Litigation / Client levels, LaneID is the direct-child // project id under which this event originates — the frontend renders // one column per lane and groups rows by LaneID. LaneID string `json:"lane_id,omitempty"` // ProjectEventType carries the underlying paliad.project_events.event_type // for milestone rows (t-paliad-176). Empty for deadline / appointment / // projected rows. The FilterBar's project_event_kind chip narrows the // rendered list against this field; KnownProjectEventKinds in // internal/services/filter_spec.go is the canonical vocabulary. ProjectEventType string `json:"project_event_type,omitempty"` // BubbleUp signals that a project_event milestone is marked to // bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2). // Read from metadata.bubble_up on the underlying paliad.project_events // row. Default-on for structural milestones (counterclaim_created, // third_party_intervention, scope_change), default-off for // custom_milestone (user can override per entry via the form // checkbox). At parent-node levels, rows with BubbleUp=true survive // the levelPolicy kind/status filter unconditionally. BubbleUp bool `json:"bubble_up,omitempty"` } // LaneInfo describes one column in the parent-node aggregated view. // Returned alongside []TimelineEvent so the frontend knows which lanes // to render, with what label, in what order. The id is opaque to the // frontend (it just groups events by ev.LaneID == lane.ID); ProjectID // lets the lane sub-header link through to the underlying project page. type LaneInfo struct { ID string `json:"id"` Label string `json:"label"` ProjectID string `json:"project_id,omitempty"` // Primary marks the "primary" lane at Litigation level — the most- // recently-active case per child patent (§5.1). Frontend can dim the // non-primary lanes or rank them lower. Empty at other levels. Primary bool `json:"primary,omitempty"` } // LevelPolicy is the (kinds, statuses, lane_axis) triple per project // type returned by levelPolicy. The lane axis identifies which direct // child type aggregates into lanes. type LevelPolicy struct { // Kinds is the allowed event kinds at this level. Empty = all. Kinds []string // Statuses is the allowed event statuses at this level. Empty = all. Statuses []string // LaneAxis identifies the lane grouping rule: // // "self_plus_ccr" — Case level: one lane for self + one per // visible CCR sub-project. // "child_case" — Patent level: one lane per direct child // case (events come from each case subtree). // "child_patent" — Litigation level: one lane per direct child // patent (events from the primary case under // each patent). // "child_litigation" — Client level: one lane per direct child // litigation (events from each litigation // subtree). LaneAxis string } // ResponseEnvelope is the wire shape of GET /api/projects/{id}/timeline // from Slice 4 onward. Slices 1-3 returned []TimelineEvent directly; // adding lanes [] forced the envelope. Frontend reads .events to // preserve the per-row contract and .lanes to drive lane-grouped // rendering at parent-node levels. type ResponseEnvelope struct { Events []TimelineEvent `json:"events"` Lanes []LaneInfo `json:"lanes"` } // ProjectionOpts narrows the SmartTimeline read. // // IncludeAuditFull — when true, project_events are loaded WITHOUT the // timeline_kind filter (every audit row, the legacy Verlauf list). // Backs the "Audit-Log anzeigen" toggle in the timeline header. // // DirectOnly — when true, narrows to rows whose project_id exactly // matches; default (false) aggregates the project + every descendant, // matching the existing "Inkl. Unterprojekte" toggle behaviour on // /projects/{id}. // // LookaheadCap — number of future projected rows to surface (Slice 2, // m/paliad#31 layer 1). 0 = use DefaultLookaheadCap. Past predicted- // overdue rows always bypass this cap. The handler clamps user input // to [1, MaxLookaheadCap]. // // Lang — language for DependsOnRuleName ("de" / "en"). Empty defaults // to "de" (Paliad's frontend default — see CLAUDE.md "Frontend default // language is German"). type ProjectionOpts struct { IncludeAuditFull bool DirectOnly bool LookaheadCap int Lang string } // ProjectionMeta summarises a projection result for the handler / frontend. // Surfaced via X-Projection-* headers on the GET /timeline response so the // wire shape stays []TimelineEvent (frozen from Slice 1) while the // frontend still gets enough info to render "Mehr anzeigen". type ProjectionMeta struct { HasProjection bool `json:"has_projection"` // true when calculator was invoked ProjectedTotal int `json:"projected_total"` // future predicted rows pre-cap (main track) ProjectedShown int `json:"projected_shown"` // future predicted rows after cap (main track) PredictedOverdue int `json:"predicted_overdue"` // overdue projection rows (main track, uncapped) Lookahead int `json:"lookahead"` // applied cap value // AvailableTracks lists the track tags present in the response — the // chip selector (`[Track ▼]`) reads this to populate the dropdown. // "parent" is always present; "counterclaim:" is added when CCR // children exist; "parent_context:" is added when the viewed // project is itself a CCR sub-project (t-paliad-174 §4.5). AvailableTracks []string `json:"available_tracks"` // Lanes describes the parallel-column layout at parent-node levels // (t-paliad-175 SmartTimeline Slice 4 §5). At Case level, lanes // mirror the available tracks (one entry for "self", one per visible // CCR sub-project, one for parent_context when applicable). At // Patent / Litigation / Client levels, lanes are the direct child // projects under the lane axis. Empty when the response should // render as a single-column flow (legacy behaviour). Lanes []LaneInfo `json:"lanes"` // SpawnCycleDropped is set when expandCrossProceedingSpawns detected // a cycle in the spawn graph and degraded to "no spawned rows" rather // than failing the projection. The SmartTimeline still renders; the // caller can log + show a "Spawn-Auflösung übersprungen" banner so the // editor knows which spawn rule to fix. Phase 3 Slice 7 (t-paliad-188). SpawnCycleDropped bool `json:"spawn_cycle_dropped,omitempty"` } // ProjectionService composes the SmartTimeline. type ProjectionService struct { db *sqlx.DB projects *ProjectService deadlines *DeadlineService appointments *AppointmentService fristen *FristenrechnerService rules *DeadlineRuleService } // NewProjectionService wires the read-side dependencies. fristen + rules // are required for Slice 2 projection; pass them in even when the caller // only intends to use actuals (the service short-circuits cleanly when // the project has no proceeding_type set). func NewProjectionService( db *sqlx.DB, projects *ProjectService, deadlines *DeadlineService, appointments *AppointmentService, fristen *FristenrechnerService, rules *DeadlineRuleService, ) *ProjectionService { return &ProjectionService{ db: db, projects: projects, deadlines: deadlines, appointments: appointments, fristen: fristen, rules: rules, } } // For builds a SmartTimeline for one project. Returns rows + meta // summarising the projection state. Visibility is delegated to the // underlying services (DeadlineService / AppointmentService / the // project_events query reuses the project visibility predicate), so // this layer adds no new RLS surface. // // Sort: actuals before projections of the same date; projections sorted // by date ASC (predicted_overdue first since they're in the past), // undated rows last. See sortTimeline for the deterministic tiebreak. // // Level policy (t-paliad-175 Slice 4 §5): // - Case (or unknown type) — full detail: own actuals + projection + // parallel-track CCR children. Lanes mirror tracks ("self" + CCR). // - Patent / Litigation / Client — lane-aggregated: load direct // children matching the axis, gather their subtree events, apply // the policy filter (kinds/statuses) with bubble_up override on // project_events, tag every row with LaneID = direct-child id. // // Track composition (t-paliad-174 §4.5) survives at Case level: // - The viewed project always emits Track="parent" rows. // - Visible CCR sub-projects emit Track="counterclaim:". // - When the viewed project is itself a CCR, the parent emits // Track="parent_context:" rows. func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, ProjectionMeta, error) { meta := ProjectionMeta{ Lookahead: applyLookaheadDefault(opts.LookaheadCap), AvailableTracks: []string{"parent"}, Lanes: []LaneInfo{}, } proj, err := s.projects.GetByID(ctx, userID, projectID) if err != nil { return nil, meta, err } policy := levelPolicy(proj.Type) // DirectOnly collapses every level to a single-lane "self" view — // no CCR sub-project lanes (Case level), no parent_context lane (CCR // child viewpoint), no child-case / child-patent / child-litigation // lanes (Patent / Litigation / Client levels). The level-policy // kind/status filter still applies at higher levels so that, e.g., a // Patent-level direct view doesn't suddenly leak off_script custom // milestones that the aggregated view filters out (t-paliad-176). if opts.DirectOnly { return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta) } // Patent / Litigation / Client levels — lane-aggregated rendering. if policy.LaneAxis != "self_plus_ccr" { return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta) } // Case level (and anything else without a known axis) — full detail // flow: parent track + CCR sub-projects + parent_context for CCR // children. return s.forCaseLevel(ctx, userID, proj, opts, meta) } // forDirectSelfOnly handles every level when DirectOnly is requested // (m/paliad#33). Renders this project's own actuals + (at Case level) // projection only — no CCR / parent_context / child-case lanes. The // policy's kind/status filter still applies at higher levels so the // "Nur direkt" Patent view honours the same milestone-only contract as // the aggregated default. Produces a single "self" lane. func (s *ProjectionService) forDirectSelfOnly( ctx context.Context, userID uuid.UUID, proj *models.Project, policy LevelPolicy, opts ProjectionOpts, meta ProjectionMeta, ) ([]TimelineEvent, ProjectionMeta, error) { includeProjection := policy.LaneAxis == "self_plus_ccr" rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection) if err != nil { return nil, meta, err } meta.HasProjection = mainMeta.HasProjection meta.ProjectedTotal = mainMeta.ProjectedTotal meta.ProjectedShown = mainMeta.ProjectedShown meta.PredictedOverdue = mainMeta.PredictedOverdue allowKind := stringSet(policy.Kinds) allowStatus := stringSet(policy.Statuses) out := make([]TimelineEvent, 0, len(rows)) for i := range rows { row := rows[i] row.LaneID = "self" if !rowSurvivesPolicy(row, allowKind, allowStatus) { continue } out = append(out, row) } meta.Lanes = append(meta.Lanes, LaneInfo{ ID: "self", Label: proj.Title, ProjectID: proj.ID.String(), }) sortTimeline(out) return out, meta, nil } // forCaseLevel runs the original Slice-1-through-3 flow: parent track + // CCR sub-projects (when this project is the parent) or parent_context // (when this project is a CCR child). Lanes mirror tracks one-for-one // at this level. func (s *ProjectionService) forCaseLevel( ctx context.Context, userID uuid.UUID, proj *models.Project, opts ProjectionOpts, meta ProjectionMeta, ) ([]TimelineEvent, ProjectionMeta, error) { projectID := proj.ID // --- Main project track (always present) --------------------------- mainRows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, true) if err != nil { return nil, meta, err } meta.HasProjection = mainMeta.HasProjection meta.ProjectedTotal = mainMeta.ProjectedTotal meta.ProjectedShown = mainMeta.ProjectedShown meta.PredictedOverdue = mainMeta.PredictedOverdue for i := range mainRows { mainRows[i].LaneID = "self" } meta.Lanes = append(meta.Lanes, LaneInfo{ ID: "self", Label: proj.Title, ProjectID: proj.ID.String(), }) out := make([]TimelineEvent, 0, len(mainRows)+16) out = append(out, mainRows...) // --- CCR sub-project tracks (parent's view) ------------------------ if proj.CounterclaimOf == nil { ccrChildren, err := s.projects.LoadCounterclaimChildrenVisible(ctx, userID, projectID) if err != nil { return nil, meta, fmt.Errorf("projection: ccr children: %w", err) } for i := range ccrChildren { child := ccrChildren[i] tag := "counterclaim:" + child.ID.String() childRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, tag, &child, true) if err != nil { return nil, meta, fmt.Errorf("projection: ccr child %s: %w", child.ID, err) } for j := range childRows { childRows[j].LaneID = tag } out = append(out, childRows...) meta.AvailableTracks = append(meta.AvailableTracks, tag) meta.Lanes = append(meta.Lanes, LaneInfo{ ID: tag, Label: child.Title, ProjectID: child.ID.String(), }) } } // --- Parent context (CCR child's view) ----------------------------- if proj.CounterclaimOf != nil { parent, err := s.projects.GetByID(ctx, userID, *proj.CounterclaimOf) if err == nil && parent != nil { tag := "parent_context:" + parent.ID.String() parentRows, _, err := s.loadProjectTrack(ctx, userID, parent, opts, tag, parent, true) if err != nil { return nil, meta, fmt.Errorf("projection: parent context: %w", err) } for j := range parentRows { parentRows[j].LaneID = tag } out = append(out, parentRows...) meta.AvailableTracks = append(meta.AvailableTracks, tag) meta.Lanes = append(meta.Lanes, LaneInfo{ ID: tag, Label: parent.Title, ProjectID: parent.ID.String(), }) } // Parent invisible to viewer (rare — usually CCR creator has // access to both): silently omit; the CCR's own track still // renders solo. } sortTimeline(out) return out, meta, nil } // forAggregatedLevel handles Patent / Litigation / Client levels per // §5: gather the direct children matching the policy's lane axis, run a // per-lane loader on each subtree, apply the kind/status filter (with // bubble_up override), and tag rows with LaneID = direct-child id. // // The projection calculator is disabled on lane-aggregated levels — // at Patent / Litigation / Client we render only actuals + opted-in // milestones, never the predicted future course (per §5.1 the future // projection is a Case-level concern; surfacing it at higher levels // would drown the user in noise). func (s *ProjectionService) forAggregatedLevel( ctx context.Context, userID uuid.UUID, proj *models.Project, policy LevelPolicy, opts ProjectionOpts, meta ProjectionMeta, ) ([]TimelineEvent, ProjectionMeta, error) { laneChildren, err := s.loadLaneChildren(ctx, userID, proj, policy) if err != nil { return nil, meta, err } out := make([]TimelineEvent, 0, len(laneChildren)*8) allowKind := stringSet(policy.Kinds) allowStatus := stringSet(policy.Statuses) for i := range laneChildren { child := laneChildren[i] laneID := child.ID.String() laneLabel := laneLabelFor(&child, policy) meta.Lanes = append(meta.Lanes, LaneInfo{ ID: laneID, Label: laneLabel, ProjectID: child.ID.String(), }) // Lane-aggregated levels skip projection — the lane loader runs // the actuals pipeline only. laneRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, "parent", nil, false) if err != nil { return nil, meta, fmt.Errorf("projection: lane child %s: %w", child.ID, err) } for j := range laneRows { row := laneRows[j] row.LaneID = laneID if !rowSurvivesPolicy(row, allowKind, allowStatus) { continue } out = append(out, row) } } sortTimeline(out) return out, meta, nil } // loadLaneChildren returns the direct children matching the policy's // lane axis, sorted deterministically. Visibility is owned by the // underlying ProjectService (each child lookup goes through visibility // predicates), so a user only ever sees lanes they're entitled to. func (s *ProjectionService) loadLaneChildren( ctx context.Context, userID uuid.UUID, proj *models.Project, policy LevelPolicy, ) ([]models.Project, error) { want := childTypeForAxis(policy.LaneAxis) if want == "" { return nil, nil } all, err := s.projects.ListChildren(ctx, userID, proj.ID) if err != nil { return nil, fmt.Errorf("projection: list lane children: %w", err) } out := make([]models.Project, 0, len(all)) for _, c := range all { if c.Type != want { continue } // Skip CCR sub-projects from the lane list — they surface as // their own column on the parent case's SmartTimeline (Slice 3 // behaviour), not as a separate lane at higher levels. if c.CounterclaimOf != nil { continue } out = append(out, c) } return out, nil } // childTypeForAxis maps a lane axis identifier to the project type the // children must have. Returns "" when the axis is unknown / not lane- // aggregated (Case level). func childTypeForAxis(axis string) string { switch axis { case "child_case": return "case" case "child_patent": return "patent" case "child_litigation": return "litigation" } return "" } // laneLabelFor picks the human-readable label for a lane sub-header. // Patent level → " ()"; Litigation level // → patent reference / patent_number; Client level → litigation title. // Falls back to the child's Title when no axis-specific identifier is // available. func laneLabelFor(child *models.Project, policy LevelPolicy) string { switch policy.LaneAxis { case "child_case": // Append the proceeding type code when known so the lawyer can // identify which case at a glance ("UPC-CFI München (upc.inf.cfi)"). if child.ProceedingTypeID != nil { return child.Title } return child.Title case "child_patent": if child.PatentNumber != nil && strings.TrimSpace(*child.PatentNumber) != "" { return strings.TrimSpace(*child.PatentNumber) } if child.Reference != nil && strings.TrimSpace(*child.Reference) != "" { return strings.TrimSpace(*child.Reference) } return child.Title case "child_litigation": return child.Title } return child.Title } // rowSurvivesPolicy applies the (kinds, statuses) filter from levelPolicy. // Bubble-up project_events override the filter unconditionally — that's // the contract for structural milestones at higher levels. func rowSurvivesPolicy(row TimelineEvent, allowKind, allowStatus map[string]bool) bool { if row.BubbleUp { return true } if len(allowKind) > 0 && !allowKind[row.Kind] { return false } if len(allowStatus) > 0 && !allowStatus[row.Status] { return false } return true } // stringSet builds a lookup map from a slice; nil/empty input returns // nil so callers can skip the filter when the policy doesn't constrain // the dimension. func stringSet(vals []string) map[string]bool { if len(vals) == 0 { return nil } out := make(map[string]bool, len(vals)) for _, v := range vals { out[v] = true } return out } // levelPolicy returns the (kinds, statuses, lane_axis) triple per // project type per design §5.1. Unknown / empty types fall back to the // Case-level policy — the safest default since it shows everything. func levelPolicy(projectType string) LevelPolicy { switch projectType { case "patent": return LevelPolicy{ Kinds: []string{"deadline", "milestone"}, Statuses: []string{"done", "open", "overdue"}, LaneAxis: "child_case", } case "litigation": return LevelPolicy{ Kinds: []string{"milestone"}, Statuses: []string{"done"}, LaneAxis: "child_patent", } case "client": return LevelPolicy{ Kinds: []string{"milestone"}, Statuses: []string{"done"}, LaneAxis: "child_litigation", } default: // Case + everything else. return LevelPolicy{LaneAxis: "self_plus_ccr"} } } // loadProjectTrack runs the actuals + projection pipeline for ONE // project and returns rows tagged with trackTag. When subProject is // non-nil, every emitted row also carries SubProjectID + SubProjectTitle // so the frontend can render the sub-project label in the column header. // // Each track applies its own lookahead cap independently — the meta // returned represents only this track. The caller decides which track's // meta surfaces in headers; today the main track's meta wins. // // includeProjection — when false, the calculator is skipped (lane- // aggregated rendering at Patent / Litigation / Client levels per §5, // where projected rows are deliberately hidden). The actuals pipeline // runs unchanged either way. func (s *ProjectionService) loadProjectTrack( ctx context.Context, userID uuid.UUID, proj *models.Project, opts ProjectionOpts, trackTag string, subProject *models.Project, includeProjection bool, ) ([]TimelineEvent, ProjectionMeta, error) { meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)} out := make([]TimelineEvent, 0, 16) projectID := proj.ID // --- Deadlines ---- deadlineRows, err := s.deadlines.ListVisibleForUser(ctx, userID, ListFilter{ ProjectID: &projectID, DirectOnly: opts.DirectOnly, }) if err != nil { return nil, meta, fmt.Errorf("projection: deadlines: %w", err) } for _, d := range deadlineRows { ev := TimelineEvent{ Kind: "deadline", Status: deadlineStatus(d.Status, d.DueDate), Track: trackTag, Date: timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)), Title: d.Title, DeadlineID: &d.ID, } if d.Description != nil { ev.Description = *d.Description } if d.RuleCode != nil { ev.RuleCode = *d.RuleCode } if d.RuleID != nil { id := *d.RuleID ev.DeadlineRuleID = &id } applySubProject(&ev, subProject) out = append(out, ev) } // --- Appointments ---- apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{ ProjectID: &projectID, DirectOnly: opts.DirectOnly, }) if err != nil { return nil, meta, fmt.Errorf("projection: appointments: %w", err) } now := time.Now().UTC() for _, a := range apptRows { startCopy := a.StartAt ev := TimelineEvent{ Kind: "appointment", Status: appointmentStatus(startCopy, now), Track: trackTag, Date: &startCopy, Title: a.Title, AppointmentID: &a.ID, } if a.Description != nil { ev.Description = *a.Description } applySubProject(&ev, subProject) out = append(out, ev) } if err := s.hydrateAppointmentRuleIDs(ctx, projectID, opts.DirectOnly, out); err != nil { return nil, meta, err } // --- Milestones ---- skippedRules, milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts) if err != nil { return nil, meta, fmt.Errorf("projection: milestones: %w", err) } for i := range milestoneRows { milestoneRows[i].Track = trackTag applySubProject(&milestoneRows[i], subProject) } out = append(out, milestoneRows...) // --- Projection (Slice 2) ---- if includeProjection { projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts) if err != nil { return nil, meta, fmt.Errorf("projection: calculate: %w", err) } for i := range projectedRows { projectedRows[i].Track = trackTag applySubProject(&projectedRows[i], subProject) } out = append(out, projectedRows...) meta.HasProjection = projMeta.HasProjection meta.ProjectedTotal = projMeta.ProjectedTotal meta.ProjectedShown = projMeta.ProjectedShown meta.PredictedOverdue = projMeta.PredictedOverdue } // --- Dependency annotations ---- if proj.ProceedingTypeID != nil && s.rules != nil { rules, err := s.rules.List(ctx, proj.ProceedingTypeID) if err == nil { s.annotateDependsOn(out, rules, lang(opts.Lang)) } } return out, meta, nil } // applySubProject fills SubProjectID + SubProjectTitle when the row // belongs to a non-primary track. No-op when subProject is nil. func applySubProject(ev *TimelineEvent, subProject *models.Project) { if subProject == nil { return } id := subProject.ID ev.SubProjectID = &id ev.SubProjectTitle = subProject.Title } // computeProjections runs FristenrechnerService.Calculate for the project // and emits TimelineEvent rows for every rule that does NOT have a // matching actual. Returns the projected rows + the meta summary. // // Lookahead cap (m/paliad#31 layer 1): future predicted rows beyond // LookaheadCap are dropped from the output; predicted-overdue rows // (past projected dates without an anchor) bypass the cap. Court-set // rules in the future surface with Status="court_set"; all other future // rules with Status="predicted". func (s *ProjectionService) computeProjections( ctx context.Context, proj *models.Project, skippedRuleCodes map[string]bool, opts ProjectionOpts, ) ([]TimelineEvent, ProjectionMeta, error) { cap := applyLookaheadDefault(opts.LookaheadCap) meta := ProjectionMeta{Lookahead: cap} if proj.ProceedingTypeID == nil || s.fristen == nil || s.rules == nil { return nil, meta, nil } // Resolve proceeding code from id. var proceedingCode string err := s.db.GetContext(ctx, &proceedingCode, `SELECT code FROM paliad.proceeding_types WHERE id = $1 AND is_active = true`, *proj.ProceedingTypeID) if errors.Is(err, sql.ErrNoRows) { return nil, meta, nil } if err != nil { return nil, meta, fmt.Errorf("resolve proceeding code: %w", err) } // Build the AnchorOverrides map from completed actuals + appointments // tied to a rule. Track which rule_ids have actuals so we can skip // emitting projected rows for them. overrides := map[string]string{} ruleIDsWithActual := map[uuid.UUID]bool{} rules, err := s.rules.List(ctx, proj.ProceedingTypeID) if err != nil { return nil, meta, fmt.Errorf("list rules for proceeding: %w", err) } ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules)) for _, r := range rules { ruleByID[r.ID] = r } // Deadlines with rule_id → override the rule's anchor with the actual // date. Prefer completed_at on done rows; fall back to due_date. if err := s.collectActualsForOverrides(ctx, proj.ID, ruleByID, opts.DirectOnly, overrides, ruleIDsWithActual); err != nil { return nil, meta, err } // Determine triggerDate. Look for the proceeding's root rule // (parent_id IS NULL) — when an actual exists for it, that's the // trigger; otherwise today as a placeholder so the projection still // computes relative dates. Once the user clicks "Datum setzen" on // the root row (e.g. SoC), the next read uses the real anchor. triggerDateStr := s.deriveTriggerDate(rules, overrides) flags := flagsForProject(proj) resp, err := s.fristen.Calculate(ctx, proceedingCode, triggerDateStr, CalcOptions{ AnchorOverrides: overrides, Flags: flags, }) if err != nil { // Calculator hiccup is non-fatal — degrade to actuals-only so // the page still renders. Log via the standard pattern. return nil, meta, nil } meta.HasProjection = true today := startOfUTCDay(time.Now().UTC()) projected := make([]TimelineEvent, 0, len(resp.Deadlines)) for _, ui := range resp.Deadlines { if ui.RuleID == "" { continue } ruleID, err := uuid.Parse(ui.RuleID) if err != nil { continue } if ruleIDsWithActual[ruleID] { // Already represented as a Kind="deadline" or "appointment" // row — skip duplicate. continue } // Rule explicitly skipped via /timeline/skip (§6.4) — drop // from cascade so the user's "ist nicht eingetreten" decision // sticks across reloads. if ui.Code != "" && skippedRuleCodes[ui.Code] { continue } rule, ok := ruleByID[ruleID] if !ok { // Defensive: the calculator returned a rule_id that isn't in // the per-proceeding map. After Phase 3 Slice 7 // (t-paliad-188) the unified FristenrechnerService.Calculate // stays scoped to one proceeding (Option A in design §6.2), // so spawned-into rules don't arrive here — they're appended // below via expandCrossProceedingSpawns. A miss now means // either a stale ruleByID (unlikely) or a future calculator // extension we haven't accounted for; skip the dependency // annotation but still surface the row. rule = models.DeadlineRule{} } ev := TimelineEvent{ Kind: "projected", Track: "parent", Title: ruleDisplayName(rule, ui, lang(opts.Lang)), RuleCode: ui.Code, DeadlineRuleParty: ui.Party, } idCopy := ruleID ev.DeadlineRuleID = &idCopy // Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for // court-set rules whose date isn't bound yet. if ui.DueDate != "" { if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil { dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) ev.Date = &dt } } switch { case ui.IsCourtSet && ev.Date == nil: // Pure court-set rule — date is bound by the court at // hearing/decision time. Surface as undated court_set. ev.Status = "court_set" case ui.IsCourtSet: // Court-set rule with a derived date (e.g. R.151 chain // off a court-set parent). Render as court_set so the UI // shows the dashed border + the "wird vom Gericht bestimmt" // nuance. ev.Status = "court_set" case ev.Date != nil && ev.Date.Before(today): // Past predicted but no anchor: surface as overdue. These // bypass the lookahead cap (§6.4 + #31 layer 1). ev.Status = "predicted_overdue" default: ev.Status = "predicted" } projected = append(projected, ev) } // Phase 3 Slice 7 (t-paliad-188): expand cross-proceeding spawn rules. // is_spawn=true rules with a non-NULL spawn_proceeding_type_id appear // in the current proceeding's rule set; we resolve each spawn target's // root rule (lowest sequence_order) via a one-shot global SELECT and // emit a spawned-into projected row anchored on the spawn source's // computed date. Cycle guard: visited-set DFS keyed by // proceeding_type_id; ErrCyclicSpawn degrades to "no spawned rows" // rather than failing the whole SmartTimeline render. if proj.ProceedingTypeID != nil { visited := map[int]bool{*proj.ProceedingTypeID: true} spawnRows, spawnErr := s.expandCrossProceedingSpawns(ctx, rules, resp.Deadlines, visited, 0) if spawnErr != nil { if !errors.Is(spawnErr, ErrCyclicSpawn) { return nil, meta, fmt.Errorf("expand spawns: %w", spawnErr) } // Cyclic spawn: drop spawned rows from this projection, // continue rendering the rest. SmartTimeline stays usable. // Surfaced in meta so the caller can log / show a banner. meta.SpawnCycleDropped = true } else if len(spawnRows) > 0 { projected = append(projected, spawnRows...) } } // Apply lookahead cap. Predicted-overdue rows are exempt — surface // all of them. Court-set undated rows are exempt too because their // position on the timeline is "future, indefinite" and dropping the // Hauptverhandlung row to make room for a Rejoinder would be wrong. cappedProjected, projTotal, projShown, overdueCount := applyLookaheadCap(projected, cap) meta.ProjectedTotal = projTotal meta.ProjectedShown = projShown meta.PredictedOverdue = overdueCount return cappedProjected, meta, nil } // expandCrossProceedingSpawns walks the spawn graph rooted at the // caller's source proceeding (the `visited` set seeds it). For each // rule in `sourceRules` with is_spawn=true AND a non-NULL // SpawnProceedingTypeID, it resolves the target proceeding's root rule // and emits a spawned-into TimelineEvent linking back to the source. // // Cycle guard: when a spawn target's proceeding_type_id is already in // `visited`, the function returns ErrCyclicSpawn wrapped with the // rule + proceeding context. The caller (computeProjections) catches // it and degrades to "no spawned rows" — better than blocking the // whole render with an error. // // Recursion: after emitting a spawned-into row, the function recurses // into the target proceeding's own spawn rules. depth is bounded by // maxSpawnDepth as a safety belt; the visited set is the real loop // guard. // // Spawn-source dates come from `sourceDeadlines` — the UIResponse the // calculator just emitted. The spawned-into row inherits the source's // computed due date as its anchor; computing the target proceeding's // own deadlines off that anchor is deferred to a follow-up slice (the // rule editor will let editors set per-rule offsets that the // projection can compose). For Slice 7 v1, the spawned-into row // surfaces undated with Status="predicted" and Track="spawn" so the // frontend renders a clear boundary divider. func (s *ProjectionService) expandCrossProceedingSpawns( ctx context.Context, sourceRules []models.DeadlineRule, sourceDeadlines []UIDeadline, visited map[int]bool, depth int, ) ([]TimelineEvent, error) { if depth >= maxSpawnDepth { return nil, fmt.Errorf("%w: max depth %d exceeded", ErrCyclicSpawn, maxSpawnDepth) } // Index source rule computed dates by rule id for anchor lookup. dateByRuleID := make(map[uuid.UUID]string, len(sourceDeadlines)) for _, ui := range sourceDeadlines { if ui.RuleID == "" || ui.DueDate == "" { continue } if id, err := uuid.Parse(ui.RuleID); err == nil { dateByRuleID[id] = ui.DueDate } } // Identify spawn rules + collect target proceeding ids. The cycle // guard runs here on each unique target — if any target is already // in `visited`, abort the whole expansion (one cyclic edge poisons // the graph; we can't selectively render around it without // fabricating an incomplete dependency tree). type spawnSource struct { rule models.DeadlineRule anchorDate string } var sources []spawnSource targetIDs := make(map[int]struct{}) for _, r := range sourceRules { if !r.IsSpawn || r.SpawnProceedingTypeID == nil { continue } if visited[*r.SpawnProceedingTypeID] { return nil, fmt.Errorf("%w: rule %s (proceeding %d) spawns into proceeding %d which is already in the chain", ErrCyclicSpawn, r.ID, derefIntPtr(r.ProceedingTypeID), *r.SpawnProceedingTypeID) } targetIDs[*r.SpawnProceedingTypeID] = struct{}{} sources = append(sources, spawnSource{rule: r, anchorDate: dateByRuleID[r.ID]}) } if len(sources) == 0 { return nil, nil } // Bulk-load target proceedings' rules in one round-trip. The result // is pre-sorted by (proceeding_type_id, sequence_order) so the // first rule per proceeding is the root (lowest sequence_order). ids := make([]int, 0, len(targetIDs)) for id := range targetIDs { ids = append(ids, id) } targetRules, err := s.rules.ListByProceedingTypeIDs(ctx, ids) if err != nil { return nil, err } // Group target rules by proceeding_type_id; first slot wins (root). firstByPT := make(map[int]models.DeadlineRule, len(ids)) rulesByPT := make(map[int][]models.DeadlineRule, len(ids)) for _, tr := range targetRules { if tr.ProceedingTypeID == nil { continue } rulesByPT[*tr.ProceedingTypeID] = append(rulesByPT[*tr.ProceedingTypeID], tr) if _, seen := firstByPT[*tr.ProceedingTypeID]; !seen { firstByPT[*tr.ProceedingTypeID] = tr } } // Render one spawned-into TimelineEvent per source rule. Recurse // into the target proceeding's spawn rules (depth + 1) with the // target's proceeding_type_id added to `visited`. var out []TimelineEvent for _, src := range sources { first, ok := firstByPT[*src.rule.SpawnProceedingTypeID] if !ok { // Target proceeding has no active rules (defensive — a // future seed could land it). Skip silently. continue } title := first.Name if src.rule.SpawnLabel != nil && *src.rule.SpawnLabel != "" { title = title + " (" + *src.rule.SpawnLabel + ")" } ev := TimelineEvent{ Kind: "projected", Status: "predicted", Track: "spawn", Title: title, DependsOnRuleName: src.rule.Name, } if first.SubmissionCode != nil { ev.RuleCode = *first.SubmissionCode } if src.rule.SubmissionCode != nil { ev.DependsOnRuleCode = *src.rule.SubmissionCode } idCopy := first.ID ev.DeadlineRuleID = &idCopy if first.PrimaryParty != nil { ev.DeadlineRuleParty = *first.PrimaryParty } // Anchor date: the spawn source's projected due date if // known. We don't compute the target's offset in Slice 7 // v1 — that's the deferred per-rule editor concern — so the // row surfaces undated when the source has no anchor. if src.anchorDate != "" { if t, perr := time.Parse("2006-01-02", src.anchorDate); perr == nil { dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) ev.DependsOnDate = &dt } } out = append(out, ev) // Recurse: walk the target's own spawn rules. Carry forward // the visited set with the target proceeding added so a // later hop back to it triggers ErrCyclicSpawn. nextVisited := make(map[int]bool, len(visited)+1) for k, v := range visited { nextVisited[k] = v } nextVisited[*src.rule.SpawnProceedingTypeID] = true sub, err := s.expandCrossProceedingSpawns(ctx, rulesByPT[*src.rule.SpawnProceedingTypeID], nil, nextVisited, depth+1) if err != nil { return out, err } out = append(out, sub...) } return out, nil } // derefIntPtr returns 0 when the pointer is nil — used only in error // messages for human-readable proceeding-id context. Never load-bearing // for the spawn-resolution logic itself (which checks for nil before // dereferencing). func derefIntPtr(p *int) int { if p == nil { return 0 } return *p } // collectActualsForOverrides loads every paliad.deadlines + paliad.appointments // row tied to a rule_id (or rule_code) for the project + descendants and // fills the overrides + ruleIDsWithActual maps. // // We bypass the WithProject list helpers because the caller has already // gated visibility via projects.GetByID + the deadline list call above // for the displayed rows. This second pass is a narrow read of (rule_id, // rule_code, completed_at, due_date) for the override map only — never // surfaced to the user. func (s *ProjectionService) collectActualsForOverrides( ctx context.Context, projectID uuid.UUID, ruleByID map[uuid.UUID]models.DeadlineRule, directOnly bool, overrides map[string]string, ruleIDsWithActual map[uuid.UUID]bool, ) error { type drow struct { RuleID *uuid.UUID `db:"rule_id"` RuleCode *string `db:"rule_code"` DueDate time.Time `db:"due_date"` CompletedAt *time.Time `db:"completed_at"` Status string `db:"status"` } var dRows []drow scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly) q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status FROM paliad.deadlines d WHERE ` + scopeFilter if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil { return fmt.Errorf("collect deadline actuals: %w", err) } for _, d := range dRows { var anchor time.Time switch { case d.CompletedAt != nil: anchor = *d.CompletedAt case d.Status == "completed": anchor = d.DueDate default: // Pending deadline — not an anchor for downstream reflow. // Still mark rule_id so we don't double-emit projected. if d.RuleID != nil { ruleIDsWithActual[*d.RuleID] = true } continue } if d.RuleID != nil { ruleIDsWithActual[*d.RuleID] = true if r, ok := ruleByID[*d.RuleID]; ok && r.SubmissionCode != nil { overrides[*r.SubmissionCode] = anchor.Format("2006-01-02") } } if d.RuleCode != nil && *d.RuleCode != "" { overrides[*d.RuleCode] = anchor.Format("2006-01-02") } } type arow struct { RuleID *uuid.UUID `db:"deadline_rule_id"` StartAt time.Time `db:"start_at"` } var aRows []arow apptScopeFilter := scopeProjectIDFilter("a", "project_id", projectID, directOnly) aq := `SELECT a.deadline_rule_id, a.start_at FROM paliad.appointments a WHERE a.deadline_rule_id IS NOT NULL AND ` + apptScopeFilter if err := s.db.SelectContext(ctx, &aRows, aq, projectID); err != nil { return fmt.Errorf("collect appointment actuals: %w", err) } for _, a := range aRows { if a.RuleID == nil { continue } ruleIDsWithActual[*a.RuleID] = true if r, ok := ruleByID[*a.RuleID]; ok && r.SubmissionCode != nil { overrides[*r.SubmissionCode] = a.StartAt.UTC().Format("2006-01-02") } } return nil } // hydrateAppointmentRuleIDs back-fills DeadlineRuleID on the actual // appointment rows in `rows` from the appointments.deadline_rule_id // column. The list service doesn't carry that field today; doing one // extra narrow query keeps the existing model + handler API stable. func (s *ProjectionService) hydrateAppointmentRuleIDs(ctx context.Context, projectID uuid.UUID, directOnly bool, rows []TimelineEvent) error { type ar struct { ID uuid.UUID `db:"id"` RuleID *uuid.UUID `db:"deadline_rule_id"` } var rs []ar scope := scopeProjectIDFilter("a", "project_id", projectID, directOnly) q := `SELECT a.id, a.deadline_rule_id FROM paliad.appointments a WHERE a.deadline_rule_id IS NOT NULL AND ` + scope if err := s.db.SelectContext(ctx, &rs, q, projectID); err != nil { return fmt.Errorf("hydrate appointment rule_ids: %w", err) } if len(rs) == 0 { return nil } byID := make(map[uuid.UUID]uuid.UUID, len(rs)) for _, r := range rs { if r.RuleID != nil { byID[r.ID] = *r.RuleID } } for i := range rows { if rows[i].Kind != "appointment" || rows[i].AppointmentID == nil { continue } if rid, ok := byID[*rows[i].AppointmentID]; ok { ridCopy := rid rows[i].DeadlineRuleID = &ridCopy } } return nil } // deriveTriggerDate picks the calculator's triggerDate. When the // proceeding's root rule (parent_id IS NULL) has been anchored, use that // date so the projection starts from reality. Otherwise fall back to // today() — the projection becomes "what would happen if filed today", // which the user fixes by clicking "Datum setzen" on the SoC row. func (s *ProjectionService) deriveTriggerDate(rules []models.DeadlineRule, overrides map[string]string) string { for _, r := range rules { if r.ParentID != nil || r.SubmissionCode == nil { continue } if anchor, ok := overrides[*r.SubmissionCode]; ok { return anchor } } return time.Now().UTC().Format("2006-01-02") } // listProjectEvents reads paliad.project_events for the timeline. Returns // the rule_skipped set (used to suppress projected rows for those rules) // alongside the surfaced TimelineEvent rows; rule_skipped events stay // hidden in the user-facing list since they record a non-event. func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) (map[string]bool, []TimelineEvent, error) { skipped := map[string]bool{} var projectFilter string if opts.DirectOnly { projectFilter = `pe.project_id = $1 AND ` + visibilityPredicatePositional("p", 2) } else { projectFilter = `$1 = ANY(string_to_array(p.path, '.')::uuid[]) AND ` + visibilityPredicatePositional("p", 2) } kindFilter := `AND (pe.timeline_kind IS NOT NULL OR pe.event_type = 'rule_skipped')` if opts.IncludeAuditFull { kindFilter = `` } type row struct { ID uuid.UUID `db:"id"` ProjectID uuid.UUID `db:"project_id"` EventType *string `db:"event_type"` Title string `db:"title"` Description *string `db:"description"` EventDate *time.Time `db:"event_date"` CreatedAt time.Time `db:"created_at"` Metadata json.RawMessage `db:"metadata"` TimelineKind *string `db:"timeline_kind"` } query := ` SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date, pe.created_at, pe.metadata, pe.timeline_kind FROM paliad.project_events pe JOIN paliad.projects p ON p.id = pe.project_id WHERE ` + projectFilter + ` ` + kindFilter + ` ORDER BY COALESCE(pe.event_date, pe.created_at) DESC, pe.id DESC` var rows []row if err := s.db.SelectContext(ctx, &rows, query, projectID, userID); err != nil { return nil, nil, fmt.Errorf("list project_events: %w", err) } out := make([]TimelineEvent, 0, len(rows)) for _, r := range rows { // rule_skipped rows: extract the rule_code from metadata so // computeProjections can drop the matching projected row, but // don't surface them as user-facing timeline rows. The decision // is captured in the audit log via /admin/audit-log. if r.EventType != nil && *r.EventType == "rule_skipped" { if code := extractMetadataString(r.Metadata, "rule_code"); code != "" { skipped[code] = true } continue } var when time.Time if r.EventDate != nil { when = *r.EventDate } else { when = r.CreatedAt } whenCopy := when ev := TimelineEvent{ Kind: "milestone", Status: milestoneStatus(r.TimelineKind, r.EventType), Track: "parent", Date: &whenCopy, Title: r.Title, ProjectEventID: &r.ID, BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind), } if r.EventType != nil { ev.ProjectEventType = *r.EventType } if r.Description != nil { ev.Description = *r.Description } out = append(out, ev) } return skipped, out, nil } // extractBubbleUp resolves the bubble_up flag for a project_events row // per design Q5 (t-paliad-175). Explicit metadata.bubble_up wins; when // absent, structural milestones (counterclaim_created, third_party_intervention, // scope_change) default to true and everything else (including // custom_milestone) defaults to false. Frontend exposes a checkbox on // the custom-milestone form so the user can override per entry. func extractBubbleUp(raw json.RawMessage, eventType, timelineKind *string) bool { if len(raw) > 0 { var m map[string]any if err := json.Unmarshal(raw, &m); err == nil { if v, ok := m["bubble_up"]; ok { switch t := v.(type) { case bool: return t case string: return strings.EqualFold(t, "true") || t == "1" } } } } if eventType != nil { switch *eventType { case "counterclaim_created", "third_party_intervention", "scope_change": return true } } // custom_milestone defaults to false (Q5 lock); user-set // metadata.bubble_up=true on the row is the only path to surface // these at higher levels. _ = timelineKind return false } // RecordCustomMilestone writes a "Eigener Meilenstein" project_event // (event_type='custom_milestone', timeline_kind='custom_milestone') // and returns the resulting TimelineEvent so the caller can append it // directly to the rendered list without a re-fetch. // // bubbleUp persists into metadata.bubble_up — when true the milestone // surfaces on the parent-node SmartTimeline at Patent / Litigation / // Client levels. The frontend's custom-milestone form exposes the // checkbox; absent the override, custom_milestone defaults to false // per design Q5. func (s *ProjectionService) RecordCustomMilestone( ctx context.Context, userID, projectID uuid.UUID, title string, description *string, occurredAt *time.Time, bubbleUp bool, ) (*TimelineEvent, error) { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil { return nil, err } if title == "" { return nil, fmt.Errorf("%w: title is required", ErrInvalidInput) } id := uuid.New() now := time.Now().UTC() var eventDate *time.Time if occurredAt != nil { ts := occurredAt.UTC() eventDate = &ts } metaJSON := json.RawMessage(`{}`) if bubbleUp { // Only persist bubble_up when true so existing rows-without-it // keep extractBubbleUp's default-off behaviour for custom // milestones. metaJSON = json.RawMessage(`{"bubble_up":true}`) } _, err := s.db.ExecContext(ctx, `INSERT INTO paliad.project_events (id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at, timeline_kind) VALUES ($1, $2, 'custom_milestone', $3, $4, $5, $6, $7::jsonb, $8, $8, 'custom_milestone')`, id, projectID, title, description, eventDate, userID, string(metaJSON), now) if err != nil { return nil, fmt.Errorf("insert custom_milestone: %w", err) } when := now if eventDate != nil { when = *eventDate } whenCopy := when ev := &TimelineEvent{ Kind: "milestone", Status: "off_script", Track: "parent", Date: &whenCopy, Title: title, ProjectEventID: &id, BubbleUp: bubbleUp, } if description != nil { ev.Description = *description } return ev, nil } // AnchorInput is the body of POST /api/projects/{id}/timeline/anchor. type AnchorInput struct { RuleCode string ActualDate time.Time Kind string // "deadline" | "appointment" | "" → derive from rule.event_type } // AnchorResult signals what was written. Exactly one of DeadlineID / // AppointmentID is non-nil. Updated=true means an existing row was // PATCH'd (idempotent re-anchor); false = new row inserted. type AnchorResult struct { DeadlineID *uuid.UUID AppointmentID *uuid.UUID Updated bool } // PredecessorMissingError is returned when the anchor write fails the // sequence guard (m/paliad#31 layer 3). The handler maps this to 409. type PredecessorMissingError struct { MissingRuleCode string MissingRuleNameDE string MissingRuleNameEN string RequestedRuleCode string RequestedRuleNameDE string RequestedRuleNameEN string } func (e *PredecessorMissingError) Error() string { return fmt.Sprintf("predecessor missing: %s requires %s to be anchored first", e.RequestedRuleCode, e.MissingRuleCode) } // IsPredecessorMissing unwraps a PredecessorMissingError if present. func IsPredecessorMissing(err error) (*PredecessorMissingError, bool) { var pme *PredecessorMissingError if errors.As(err, &pme) { return pme, true } return nil, false } // RecordAnchor writes (or PATCHes) the actual occurrence of a rule for // the given project. Implements the §6 click-to-anchor + #31 layer 3 // sequence guard: // // 1. Resolve the rule by (proceeding_type_id, code). // 2. If rule has parent_id, verify the parent has an anchored actual // for this project — return PredecessorMissingError if not. // 3. Court-set rules (event_type IN ('hearing','decision','order')) // write a paliad.appointments row with deadline_rule_id set; all // other rules write a paliad.deadlines row with source='anchor'. // 4. Idempotent: if a row already exists for (project_id, rule_id), // PATCH it instead of inserting (race-safe per design §13). // // Visibility: caller must have admin / lead / member responsibility on // the project — checked via DeadlineService.assertCanAdminProject (the // existing pattern). func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID uuid.UUID, in AnchorInput) (*AnchorResult, error) { proj, err := s.projects.GetByID(ctx, userID, projectID) if err != nil { return nil, err } if proj.ProceedingTypeID == nil { return nil, fmt.Errorf("%w: project has no proceeding type set", ErrInvalidInput) } if in.RuleCode == "" { return nil, fmt.Errorf("%w: rule_code is required", ErrInvalidInput) } if in.ActualDate.IsZero() { return nil, fmt.Errorf("%w: actual_date is required", ErrInvalidInput) } if err := s.deadlines.assertCanAdminProject(ctx, userID, projectID); err != nil { return nil, err } rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode) if err != nil { return nil, err } // Sequence guard. The rule's parent_id is the dependency anchor for // reflow; we reject the write when the parent has no anchored actual // for this project. v1 rejects without a confirm-and-write override // (per brief defaults locked above the impl). if rule.ParentID != nil { parentRule, err := s.lookupRuleByID(ctx, *rule.ParentID) if err != nil { return nil, fmt.Errorf("lookup parent rule: %w", err) } anchored, err := s.parentHasAnchoredActual(ctx, projectID, *rule.ParentID) if err != nil { return nil, fmt.Errorf("check parent anchor: %w", err) } if !anchored { parentCode := "" if parentRule.SubmissionCode != nil { parentCode = *parentRule.SubmissionCode } return nil, &PredecessorMissingError{ MissingRuleCode: parentCode, MissingRuleNameDE: parentRule.Name, MissingRuleNameEN: parentRule.NameEN, RequestedRuleCode: in.RuleCode, RequestedRuleNameDE: rule.Name, RequestedRuleNameEN: rule.NameEN, } } } kind := strings.ToLower(strings.TrimSpace(in.Kind)) if kind == "" { kind = ruleAnchorKind(rule) } switch kind { case "appointment": return s.upsertAnchorAppointment(ctx, userID, projectID, rule, in.ActualDate) case "deadline": return s.upsertAnchorDeadline(ctx, userID, projectID, rule, in.ActualDate) default: return nil, fmt.Errorf("%w: unknown kind %q", ErrInvalidInput, kind) } } // RecordRuleSkipped marks a projected rule as "ist nicht eingetreten / // wurde verschoben" (§6.4). Writes a paliad.project_events row with // event_type='rule_skipped', metadata={rule_code, reason}; computeProjections // uses the rule_code to drop the matching projected row from future reads. func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, projectID uuid.UUID, ruleCode, reason string) error { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil { return err } if err := s.deadlines.assertCanAdminProject(ctx, userID, projectID); err != nil { return err } if ruleCode == "" { return fmt.Errorf("%w: rule_code is required", ErrInvalidInput) } id := uuid.New() now := time.Now().UTC() meta := map[string]any{"rule_code": ruleCode} if reason != "" { meta["reason"] = reason } mb, _ := json.Marshal(meta) _, err := s.db.ExecContext(ctx, `INSERT INTO paliad.project_events (id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at, timeline_kind) VALUES ($1, $2, 'rule_skipped', $3, $4, $5, $6, $7::jsonb, $5, $5, NULL)`, id, projectID, "Regel übersprungen: "+ruleCode, nilIfEmpty(reason), now, userID, string(mb)) if err != nil { return fmt.Errorf("insert rule_skipped: %w", err) } return nil } // lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code) // → DeadlineRule. func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) { var rule models.DeadlineRule err := s.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`, ptID, code) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, code) } if err != nil { return nil, fmt.Errorf("lookup rule by submission_code: %w", err) } return &rule, nil } // lookupRuleByID resolves a rule by UUID. func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) { var rule models.DeadlineRule err := s.db.GetContext(ctx, &rule, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id) if err != nil { return nil, fmt.Errorf("lookup rule by id: %w", err) } return &rule, nil } // parentHasAnchoredActual returns true when paliad.deadlines OR // paliad.appointments has a row tied to parentRuleID for this project // AND that row represents a real anchor (deadline.completed_at / // deadline.status='completed' / source='anchor', appointment present). // // Pending deadlines (status='pending') don't count as anchors — the user // hasn't recorded the actual yet, and downstream reflow needs a concrete // date. func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, projectID, parentRuleID uuid.UUID) (bool, error) { var count int err := s.db.GetContext(ctx, &count, ` SELECT COUNT(*) FROM ( SELECT 1 FROM paliad.deadlines WHERE project_id = $1 AND rule_id = $2 AND (completed_at IS NOT NULL OR status = 'completed' OR source = 'anchor') UNION ALL SELECT 1 FROM paliad.appointments WHERE project_id = $1 AND deadline_rule_id = $2 ) t`, projectID, parentRuleID) if err != nil { return false, err } return count > 0, nil } // ruleAnchorKind picks the write kind for a rule based on its event_type. // Court-set rules (hearing / decision / order) anchor as appointments; // everything else anchors as a deadline. The brief locks this default. func ruleAnchorKind(rule *models.DeadlineRule) string { if rule == nil || rule.EventType == nil { return "deadline" } switch *rule.EventType { case "hearing", "decision", "order": return "appointment" } return "deadline" } // upsertAnchorDeadline inserts or PATCHes a paliad.deadlines row for the // (project_id, rule_id) pair. Idempotent: re-anchoring the same rule // updates due_date + completed_at instead of double-inserting. func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, projectID uuid.UUID, rule *models.DeadlineRule, actual time.Time) (*AnchorResult, error) { dateStr := actual.UTC().Format("2006-01-02") now := time.Now().UTC() var existingID uuid.UUID err := s.db.GetContext(ctx, &existingID, `SELECT id FROM paliad.deadlines WHERE project_id = $1 AND rule_id = $2 ORDER BY created_at ASC LIMIT 1`, projectID, rule.ID) switch { case errors.Is(err, sql.ErrNoRows): // fresh insert case err != nil: return nil, fmt.Errorf("check existing deadline: %w", err) default: // PATCH — flip to status=done, source='anchor', due_date=actual. _, err := s.db.ExecContext(ctx, ` UPDATE paliad.deadlines SET due_date = $1::date, completed_at = $2, status = 'completed', source = 'anchor', updated_at = $2 WHERE id = $3`, dateStr, now, existingID) if err != nil { return nil, fmt.Errorf("patch deadline anchor: %w", err) } return &AnchorResult{DeadlineID: &existingID, Updated: true}, nil } id := uuid.New() title := rule.Name ruleCode := "" if rule.SubmissionCode != nil { ruleCode = *rule.SubmissionCode } _, err = s.db.ExecContext(ctx, ` INSERT INTO paliad.deadlines (id, project_id, title, due_date, original_due_date, source, rule_id, rule_code, status, completed_at, created_by, created_at, updated_at, approval_status) VALUES ($1, $2, $3, $4::date, $4::date, 'anchor', $5, NULLIF($6, ''), 'completed', $7, $8, $7, $7, 'approved')`, id, projectID, title, dateStr, rule.ID, ruleCode, now, userID) if err != nil { return nil, fmt.Errorf("insert deadline anchor: %w", err) } return &AnchorResult{DeadlineID: &id, Updated: false}, nil } // upsertAnchorAppointment inserts or PATCHes a paliad.appointments row // for the (project_id, deadline_rule_id) pair. Mirror of upsertAnchorDeadline // for court-set rules (hearing / decision / order). func (s *ProjectionService) upsertAnchorAppointment(ctx context.Context, userID, projectID uuid.UUID, rule *models.DeadlineRule, actual time.Time) (*AnchorResult, error) { now := time.Now().UTC() apptType := "hearing" if rule.EventType != nil { switch *rule.EventType { case "decision": apptType = "deadline_hearing" // closest match in the existing CHECK; no 'decision' value yet case "order": apptType = "deadline_hearing" case "hearing": apptType = "hearing" } } var existingID uuid.UUID err := s.db.GetContext(ctx, &existingID, `SELECT id FROM paliad.appointments WHERE project_id = $1 AND deadline_rule_id = $2 ORDER BY created_at ASC LIMIT 1`, projectID, rule.ID) switch { case errors.Is(err, sql.ErrNoRows): // fresh insert case err != nil: return nil, fmt.Errorf("check existing appointment: %w", err) default: _, err := s.db.ExecContext(ctx, ` UPDATE paliad.appointments SET start_at = $1, updated_at = $2 WHERE id = $3`, actual.UTC(), now, existingID) if err != nil { return nil, fmt.Errorf("patch appointment anchor: %w", err) } return &AnchorResult{AppointmentID: &existingID, Updated: true}, nil } id := uuid.New() _, err = s.db.ExecContext(ctx, ` INSERT INTO paliad.appointments (id, project_id, title, start_at, appointment_type, deadline_rule_id, created_by, created_at, updated_at, approval_status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, 'approved')`, id, projectID, rule.Name, actual.UTC(), apptType, rule.ID, userID, now) if err != nil { return nil, fmt.Errorf("insert appointment anchor: %w", err) } return &AnchorResult{AppointmentID: &id, Updated: false}, nil } // annotateDependsOn fills DependsOnRuleCode/Date/Name on every row that // has a DeadlineRuleID by walking the rule's parent_id chain. The parent // date prefers any actual date already on a sibling row (so the user // sees real chains), falling back to the projected date for that rule. func (s *ProjectionService) annotateDependsOn(rows []TimelineEvent, rules []models.DeadlineRule, lang string) { if len(rules) == 0 { return } ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules)) for _, r := range rules { ruleByID[r.ID] = r } dateByRuleID := make(map[uuid.UUID]time.Time, len(rows)) for _, r := range rows { if r.DeadlineRuleID == nil || r.Date == nil { continue } // Prefer actuals over projections — when both exist for the // same rule (shouldn't, but defensive), the actual wins because // it's recorded reality. if r.Kind != "projected" || dateByRuleID[*r.DeadlineRuleID].IsZero() { dateByRuleID[*r.DeadlineRuleID] = *r.Date } } for i := range rows { ev := &rows[i] if ev.DeadlineRuleID == nil { continue } rule, ok := ruleByID[*ev.DeadlineRuleID] if !ok || rule.ParentID == nil { continue } parent, ok := ruleByID[*rule.ParentID] if !ok { continue } if parent.SubmissionCode != nil { ev.DependsOnRuleCode = *parent.SubmissionCode } ev.DependsOnRuleName = ruleNameInLang(parent, lang) if dt, ok := dateByRuleID[parent.ID]; ok && !dt.IsZero() { d := dt ev.DependsOnDate = &d } } } // ---------------------------------------------------------------------- // Pure helpers — kept package-private and testable without DB. // ---------------------------------------------------------------------- // applyLookaheadDefault clamps the user-supplied cap to [1, MaxLookaheadCap] // or returns DefaultLookaheadCap when the input is zero. func applyLookaheadDefault(n int) int { if n <= 0 { return DefaultLookaheadCap } if n > MaxLookaheadCap { return MaxLookaheadCap } return n } // applyLookaheadCap drops future predicted rows beyond cap. Returns the // trimmed slice + counts. Predicted-overdue rows + court-set rows are // exempt from the cap. func applyLookaheadCap(rows []TimelineEvent, cap int) (kept []TimelineEvent, projTotal, projShown, overdueCount int) { if cap <= 0 { cap = DefaultLookaheadCap } // Sort future predicted by date ASC so "next 7" is deterministic. type indexed struct { ev TimelineEvent key string } var futurePred []indexed other := make([]TimelineEvent, 0, len(rows)) for _, r := range rows { if r.Status == "predicted_overdue" { overdueCount++ other = append(other, r) continue } if r.Status == "predicted" && r.Date != nil { projTotal++ key := r.Date.Format("2006-01-02") + "|" + r.RuleCode + "|" + r.Title futurePred = append(futurePred, indexed{ev: r, key: key}) continue } // court_set, undated, or non-projection — pass through. other = append(other, r) } sort.SliceStable(futurePred, func(i, j int) bool { return futurePred[i].key < futurePred[j].key }) if len(futurePred) > cap { futurePred = futurePred[:cap] } projShown = len(futurePred) kept = other for _, p := range futurePred { kept = append(kept, p.ev) } return } // flagsForProject builds the CalcOptions.Flags set from the project's // metadata. Slice 2 honours the existing condition_flag system (with_ccr // etc.); Slice 3 will surface this via project columns once the CCR // sub-project FK lands. For now, no flags are set unless the project // metadata explicitly carries them. func flagsForProject(p *models.Project) []string { var flags []string if len(p.Metadata) == 0 { return flags } var meta map[string]any if err := json.Unmarshal(p.Metadata, &meta); err != nil { return flags } if raw, ok := meta["fristen_flags"]; ok { if arr, ok := raw.([]any); ok { for _, v := range arr { if s, ok := v.(string); ok && s != "" { flags = append(flags, s) } } } } return flags } // ruleDisplayName picks a display name for a projected row. Prefers the // rule's name in the requested language; falls back to the calculator's // UI name (which already handles translation in some cases). func ruleDisplayName(rule models.DeadlineRule, ui UIDeadline, lang string) string { if name := ruleNameInLang(rule, lang); name != "" { return name } if lang == "en" && ui.NameEN != "" { return ui.NameEN } return ui.Name } func ruleNameInLang(rule models.DeadlineRule, lang string) string { if lang == "en" && rule.NameEN != "" { return rule.NameEN } return rule.Name } // lang normalises the option string. Empty / unknown defaults to "de" // (Paliad's frontend default). func lang(s string) string { switch strings.ToLower(strings.TrimSpace(s)) { case "en": return "en" default: return "de" } } // extractMetadataString reads a top-level string field from a JSON-encoded // metadata blob. Used to pull rule_code out of paliad.project_events.metadata // for rule_skipped rows. func extractMetadataString(raw json.RawMessage, key string) string { if len(raw) == 0 { return "" } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return "" } if v, ok := m[key]; ok { if s, ok := v.(string); ok { return s } } return "" } // scopeProjectIDFilter renders a where-clause fragment for "project_id // matches projectID exactly" (DirectOnly) or "project_id is in projectID's // path" (subtree). Always uses positional $1 for projectID — the caller // passes projectID as the first argument. func scopeProjectIDFilter(alias, column string, _ uuid.UUID, directOnly bool) string { if directOnly { return fmt.Sprintf("%s.%s = $1", alias, column) } return fmt.Sprintf( `%s.%s IN ( SELECT id FROM paliad.projects WHERE $1 = ANY(string_to_array(path, '.')::uuid[]) )`, alias, column) } func nilIfEmpty(s string) any { if s == "" { return nil } return s } // deadlineStatus maps the deadline row's status + due date into the // SmartTimeline status vocabulary. func deadlineStatus(rawStatus string, due time.Time) string { if rawStatus == "completed" { return "done" } today := startOfUTCDay(time.Now().UTC()) dueDay := startOfUTCDay(due) if dueDay.Before(today) { return "overdue" } return "open" } // appointmentStatus is "done" once the start has passed, "open" otherwise. func appointmentStatus(start, now time.Time) string { if start.Before(now) { return "done" } return "open" } // milestoneStatus picks a status for project_event timeline rows. func milestoneStatus(timelineKind, eventType *string) string { if timelineKind != nil && *timelineKind == "custom_milestone" { return "off_script" } if eventType != nil && *eventType == "custom_milestone" { return "off_script" } return "done" } // sortTimeline orders rows by Date ASC with undated rows pinned at the // end; ties break on actuals-first, then kindOrder, then title, then // provenance id so the order is fully deterministic across requests. func sortTimeline(rows []TimelineEvent) { sort.SliceStable(rows, func(i, j int) bool { di, dj := rows[i].Date, rows[j].Date if di == nil && dj == nil { return timelineTiebreak(rows[i], rows[j]) } if di == nil { return false } if dj == nil { return true } if di.Equal(*dj) { return timelineTiebreak(rows[i], rows[j]) } return di.Before(*dj) }) } // timelineTiebreak: actuals before projections of the same date (deadline // > appointment > milestone > projected); same-kind ties fall back to // title then to the stringified provenance UUID. func timelineTiebreak(a, b TimelineEvent) bool { if a.Kind != b.Kind { return kindOrder(a.Kind) < kindOrder(b.Kind) } if a.Title != b.Title { return a.Title < b.Title } return timelineRowID(a) < timelineRowID(b) } func kindOrder(kind string) int { switch kind { case "deadline": return 0 case "appointment": return 1 case "milestone": return 2 case "projected": return 3 } return 4 } func timelineRowID(ev TimelineEvent) string { switch { case ev.DeadlineID != nil: return ev.DeadlineID.String() case ev.AppointmentID != nil: return ev.AppointmentID.String() case ev.ProjectEventID != nil: return ev.ProjectEventID.String() case ev.DeadlineRuleID != nil: return ev.DeadlineRuleID.String() } return "" } func startOfUTCDay(t time.Time) time.Time { t = t.UTC() return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } func timePtr(t time.Time) *time.Time { return &t }