feat(t-paliad-175): SmartTimeline Slice 4 — backend levelPolicy + lane aggregation + bubble-up

ProjectionService now dispatches on project type per design §5.1:
- Case (and unknown) — full detail flow: parent track + CCR sub-projects
  + parent_context for CCR children. Lanes mirror tracks ("self" +
  "counterclaim:<id>" + "parent_context:<id>").
- Patent / Litigation / Client — lane-aggregated: load direct children
  matching the axis (cases / patents / litigations), gather subtree
  events per lane, apply (kinds, statuses) filter, tag rows with
  LaneID = direct-child id. Calculator skipped at higher levels —
  predicted future is a Case-level concern.

levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
triple. Patent = deadlines+milestones with done/open/overdue;
Litigation + Client = milestones with done.

metadata.bubble_up on paliad.project_events (no schema change — uses
existing jsonb column) overrides the kind/status filter at higher
levels. Defaults per Q5: counterclaim_created / third_party_intervention
/ scope_change → true; custom_milestone → false (user opts in via
form checkbox). insertCounterclaimEvent now sets bubble_up=true on
both parent + child audit rows so the counterclaim_created milestone
surfaces at Patent / Litigation / Client.

Wire shape changed from []TimelineEvent to envelope {events, lanes} —
lane metadata can ride alongside the rows without exceeding header-
size limits when a Client-level projection has many lanes. Frontend
reads .events for the per-row contract and .lanes for parallel-column
rendering. X-Projection-* headers preserved for Slice 1-3 affordances
(lookahead toggle, track chip).

RecordCustomMilestone gains a bubbleUp bool param; persisted to
metadata.bubble_up only when true (so existing rows-without-it keep
the default-off behaviour).

Tests: TestLevelPolicy locks the triple table; TestRowSurvivesPolicy_
BubbleUpOverridesFilter pins the override contract; TestExtractBubbleUp
covers all per-event-type defaults + explicit override paths;
TestChildTypeForAxis pins the axis → type map. Live integration test
TestProjectionService_LevelAggregation_Live walks the patent-level
fixture: bubbled-up milestone surfaces, regular custom_milestone is
filtered, deadlines surface at Patent level.

Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
This commit is contained in:
m
2026-05-09 16:22:07 +02:00
parent 91d3811276
commit 7da8802f9b
6 changed files with 889 additions and 33 deletions

View File

@@ -1239,16 +1239,25 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
}
// Audit rows on both parent and child for symmetric trail. Both rows
// opt into the SmartTimeline via timeline_kind='milestone'.
// opt into the SmartTimeline via timeline_kind='milestone'. The
// bubble_up=true flag (t-paliad-175 §5.3 Q5) lets these structural
// milestones surface on Patent / Litigation / Client SmartTimelines
// even though the level policy filters out other milestones.
if err := insertCounterclaimEvent(ctx, tx, id, userID,
"Widerklage (CCR) angelegt",
map[string]any{"counterclaim_of": parentID.String()},
map[string]any{
"counterclaim_of": parentID.String(),
"bubble_up": true,
},
); err != nil {
return nil, err
}
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
"Widerklage (CCR) angelegt",
map[string]any{"counterclaim_id": id.String()},
map[string]any{
"counterclaim_id": id.String(),
"bubble_up": true,
},
); err != nil {
return nil, err
}

View File

@@ -0,0 +1,271 @@
package services
// Live-DB integration test for parent-node lane aggregation
// (t-paliad-175 SmartTimeline Slice 4 §5). Skipped without TEST_DATABASE_URL.
//
// Builds a 3-level fixture (Patent → Case-A + Case-B → CCR-A) and walks
// the level policy at each viewpoint:
//
// - Case-A view: full detail + CCR sub-project track (single project,
// own actuals + projection, "self" lane + "counterclaim:<id>" lane).
// - Patent view: lanes per child case; events from each case subtree;
// deadlines + milestones surface, statuses done/open/overdue.
// - Bubble-up: a counterclaim_created milestone (default-on bubble_up)
// surfaces at Patent level under Case-A's lane.
// - Custom milestone with bubble_up=true surfaces too; without, it's
// filtered out.
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestProjectionService_LevelAggregation_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
patentID := uuid.New()
caseAID := uuid.New()
caseBID := uuid.New()
cleanup := func() {
// CCR children (counterclaim_of points at one of the cases)
// must go first so the FK doesn't block the case delete.
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of IN ($1, $2)`, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'level-agg-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, 'level-agg-test@hlc.com', 'Level Agg Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Patent hub.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
VALUES ($1, 'patent', $1::text, 'EP9999999 — Test Patent', 'EP9999999', 'active', $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent team: %v", err)
}
// Case-A under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case A', 'active', $3)`,
caseAID, patentID, userID); err != nil {
t.Fatalf("seed case A: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
caseAID, userID); err != nil {
t.Fatalf("seed case A team: %v", err)
}
// Case-B under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case B', 'active', $3)`,
caseBID, patentID, userID); err != nil {
t.Fatalf("seed case B: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
caseBID, userID); err != nil {
t.Fatalf("seed case B team: %v", err)
}
// Case-A: one open deadline + one done milestone (bubble_up=true via
// counterclaim_created event_type) + one custom_milestone (bubble_up=false).
now := time.Now().UTC()
deadlineA := uuid.New()
bubbledMilestoneA := uuid.New()
regularMilestoneA := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, due_date, source, status, created_by)
VALUES ($1, $2, 'Case-A open deadline', $3::date, 'manual', 'pending', $4)`,
deadlineA, caseAID, now.AddDate(0, 0, 14).Format("2006-01-02"), userID); err != nil {
t.Fatalf("seed deadline A: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'counterclaim_created', 'Widerklage angelegt', $3, $4,
'{"bubble_up":true}'::jsonb, $5, $5, 'milestone')`,
bubbledMilestoneA, caseAID, now.AddDate(0, 0, -7), userID, now); err != nil {
t.Fatalf("seed bubbled milestone A: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', 'Random Note (no bubble)', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
regularMilestoneA, caseAID, now.AddDate(0, 0, -3), userID, now); err != nil {
t.Fatalf("seed regular milestone A: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
eventTypes := NewEventTypeService(pool, users)
deadlines := NewDeadlineService(pool, projects, eventTypes)
appointments := NewAppointmentService(pool, projects)
rules := NewDeadlineRuleService(pool)
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
fristen := NewFristenrechnerService(rules, holidays, courts)
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
t.Run("Case-level: lanes mirror tracks (self + CCR)", func(t *testing.T) {
_, meta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
if err != nil {
t.Fatalf("For caseA: %v", err)
}
// At least the "self" lane is present.
var sawSelf bool
for _, l := range meta.Lanes {
if l.ID == "self" {
sawSelf = true
if l.Label != "Case A" {
t.Errorf("self lane label = %q, want Case A", l.Label)
}
}
}
if !sawSelf {
t.Errorf("Lanes = %v, want a 'self' entry", meta.Lanes)
}
})
t.Run("Patent-level: lanes per child case + milestones bubble", func(t *testing.T) {
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
if err != nil {
t.Fatalf("For patent: %v", err)
}
// Lanes: one per child case.
laneIDs := map[string]LaneInfo{}
for _, l := range meta.Lanes {
laneIDs[l.ID] = l
}
if _, ok := laneIDs[caseAID.String()]; !ok {
t.Errorf("Lanes missing Case-A entry: %v", meta.Lanes)
}
if _, ok := laneIDs[caseBID.String()]; !ok {
t.Errorf("Lanes missing Case-B entry: %v", meta.Lanes)
}
// Bubbled-up milestone (counterclaim_created) surfaces under
// Case-A's lane.
var sawBubbled, sawRegular, sawDeadline bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
sawBubbled = true
if r.LaneID != caseAID.String() {
t.Errorf("bubbled milestone LaneID = %q, want %s", r.LaneID, caseAID.String())
}
if !r.BubbleUp {
t.Errorf("bubbled milestone BubbleUp should be true")
}
}
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
sawRegular = true
}
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
sawDeadline = true
if r.LaneID != caseAID.String() {
t.Errorf("deadline LaneID = %q, want %s", r.LaneID, caseAID.String())
}
}
}
if !sawBubbled {
t.Errorf("bubbled milestone (counterclaim_created) should surface at Patent level")
}
// Patent policy = milestones + deadlines, statuses done/open/overdue.
// The pending deadline (status=open) survives; the regular custom
// milestone (off_script status, no bubble_up) is filtered out.
if !sawDeadline {
t.Errorf("Case-A's open deadline should surface at Patent level (kinds=deadline allowed)")
}
if sawRegular {
t.Errorf("regular custom_milestone (no bubble_up, off_script status) should be filtered at Patent level")
}
})
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
// Re-write the regular milestone with bubble_up=true and confirm
// it surfaces. Then revert.
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.project_events
SET metadata = '{"bubble_up":true}'::jsonb
WHERE id = $1`, regularMilestoneA); err != nil {
t.Fatalf("flip bubble_up: %v", err)
}
defer pool.ExecContext(ctx,
`UPDATE paliad.project_events SET metadata = '{}'::jsonb WHERE id = $1`,
regularMilestoneA)
rows, _, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
if err != nil {
t.Fatalf("For patent (after flip): %v", err)
}
var saw bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
saw = true
if !r.BubbleUp {
t.Errorf("flipped milestone BubbleUp should be true")
}
}
}
if !saw {
t.Errorf("custom_milestone with bubble_up=true should surface at Patent level")
}
})
}

View File

@@ -15,7 +15,21 @@ package services
// derived from a deadline_rule with a parent_id.
// - Anchor + skip write paths (RecordAnchor, RecordRuleSkipped).
//
// See docs/design-smart-timeline-2026-05-08.md §6 + §9 + §10
// 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 (
@@ -92,6 +106,73 @@ type TimelineEvent struct {
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:<id>" for CCR
// children, "parent_context:<id>" 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"`
// 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.
@@ -137,6 +218,15 @@ type ProjectionMeta struct {
// children exist; "parent_context:<id>" 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"`
}
// ProjectionService composes the SmartTimeline.
@@ -181,17 +271,24 @@ func NewProjectionService(
// by date ASC (predicted_overdue first since they're in the past),
// undated rows last. See sortTimeline for the deterministic tiebreak.
//
// Track composition (t-paliad-174 §4.5):
// 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 (paliad.projects.counterclaim_of = self)
// emit Track="counterclaim:<child_id>" rows alongside.
// - When the viewed project is itself a CCR (counterclaim_of != nil),
// the parent emits Track="parent_context:<parent_id>" rows so the
// lawyer working the CCR sees the main proceeding without leaving.
// - Visible CCR sub-projects emit Track="counterclaim:<child_id>".
// - When the viewed project is itself a CCR, the parent emits
// Track="parent_context:<parent_id>" 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)
@@ -199,8 +296,34 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
return nil, meta, err
}
policy := levelPolicy(proj.Type)
// 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)
}
// 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)
mainRows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, true)
if err != nil {
return nil, meta, err
}
@@ -209,6 +332,15 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
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...)
@@ -221,12 +353,20 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
for i := range ccrChildren {
child := ccrChildren[i]
tag := "counterclaim:" + child.ID.String()
childRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, tag, &child)
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(),
})
}
}
@@ -235,12 +375,20 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
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)
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
@@ -251,6 +399,199 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
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 → "<case title> (<proceeding code>)"; 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)").
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
@@ -259,6 +600,11 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
// 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,
@@ -266,6 +612,7 @@ func (s *ProjectionService) loadProjectTrack(
opts ProjectionOpts,
trackTag string,
subProject *models.Project,
includeProjection bool,
) ([]TimelineEvent, ProjectionMeta, error) {
meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)}
out := make([]TimelineEvent, 0, 16)
@@ -344,19 +691,21 @@ func (s *ProjectionService) loadProjectTrack(
out = append(out, milestoneRows...)
// --- Projection (Slice 2) ----
projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts)
if err != nil {
return nil, meta, fmt.Errorf("projection: calculate: %w", err)
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
}
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 {
@@ -749,6 +1098,7 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
Date: &whenCopy,
Title: r.Title,
ProjectEventID: &r.ID,
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
}
if r.Description != nil {
ev.Description = *r.Description
@@ -758,16 +1108,56 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
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
@@ -784,12 +1174,20 @@ func (s *ProjectionService) RecordCustomMilestone(
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, '{}'::jsonb, $7, $7, 'custom_milestone')`,
id, projectID, title, description, eventDate, userID, now)
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)
}
@@ -806,6 +1204,7 @@ func (s *ProjectionService) RecordCustomMilestone(
Date: &whenCopy,
Title: title,
ProjectEventID: &id,
BubbleUp: bubbleUp,
}
if description != nil {
ev.Description = *description

View File

@@ -211,7 +211,7 @@ func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
desc := "from RecordCustomMilestone test"
when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when)
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when, false)
if err != nil {
t.Fatalf("RecordCustomMilestone: %v", err)
}

View File

@@ -6,6 +6,7 @@ package services
// covers the SQL paths.
import (
"encoding/json"
"testing"
"time"
@@ -152,6 +153,169 @@ func TestKindOrder(t *testing.T) {
}
}
// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
// These are user-visible policy decisions — locked here to catch
// accidental shifts during refactors.
func TestLevelPolicy(t *testing.T) {
cases := []struct {
projectType string
kinds []string
statuses []string
laneAxis string
}{
{"case", nil, nil, "self_plus_ccr"},
{"", nil, nil, "self_plus_ccr"}, // unknown falls back to case behaviour
{"unknown", nil, nil, "self_plus_ccr"},
{
"patent",
[]string{"deadline", "milestone"},
[]string{"done", "open", "overdue"},
"child_case",
},
{
"litigation",
[]string{"milestone"},
[]string{"done"},
"child_patent",
},
{
"client",
[]string{"milestone"},
[]string{"done"},
"child_litigation",
},
}
for _, c := range cases {
t.Run(c.projectType, func(t *testing.T) {
got := levelPolicy(c.projectType)
if got.LaneAxis != c.laneAxis {
t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
}
if !sliceEqual(got.Kinds, c.kinds) {
t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
}
if !sliceEqual(got.Statuses, c.statuses) {
t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
}
})
}
}
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
// a project_event milestone with bubble_up=true survives the level
// policy's kind/status filter at higher levels (design §5.3 + Q5).
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
allowKind := stringSet([]string{"deadline"}) // milestones excluded
allowStatus := stringSet([]string{"done"}) // off_script excluded
bubbledMilestone := TimelineEvent{
Kind: "milestone",
Status: "off_script",
BubbleUp: true,
}
if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
t.Error("bubble_up=true row should survive both kind and status filters")
}
regularMilestone := TimelineEvent{
Kind: "milestone",
Status: "off_script",
}
if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
t.Error("regular milestone should be filtered when kind/status both excluded")
}
// kind allowed, status excluded → drop.
allowedKindBadStatus := TimelineEvent{
Kind: "deadline",
Status: "open",
}
if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
t.Error("excluded status should drop a row even when kind allowed")
}
// kind excluded, status allowed → drop.
badKindGoodStatus := TimelineEvent{
Kind: "appointment",
Status: "done",
}
if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
t.Error("excluded kind should drop a row even when status allowed")
}
// Empty filters = pass-through.
if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
t.Error("empty filters should pass everything")
}
}
// TestExtractBubbleUp pins the per-event-type defaults (Q5):
// - counterclaim_created / third_party_intervention / scope_change
// default to true.
// - custom_milestone defaults to false.
// - Explicit metadata.bubble_up always wins.
func TestExtractBubbleUp(t *testing.T) {
str := func(s string) *string { return &s }
cases := []struct {
name string
raw string
eventType *string
timelineKind *string
want bool
}{
{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
{"scope_change defaults true", "", str("scope_change"), nil, true},
{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
{"unknown defaults false", "{}", str("note_created"), nil, false},
{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
{"empty metadata + nil event_type = false", "", nil, nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
if got != c.want {
t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
}
})
}
}
// TestChildTypeForAxis pins the axis → project type map.
func TestChildTypeForAxis(t *testing.T) {
cases := map[string]string{
"child_case": "case",
"child_patent": "patent",
"child_litigation": "litigation",
"self_plus_ccr": "",
"": "",
"bogus": "",
}
for axis, want := range cases {
if got := childTypeForAxis(axis); got != want {
t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
}
}
}
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (t-paliad-174 §11 Q2):
// - Default (override nil): claimant ↔ defendant; court / both pass through.