Compare commits

..

3 Commits

Author SHA1 Message Date
m
7e57507a92 feat(t-paliad-175): SmartTimeline Slice 4 — frontend lane render + Client Timeline-Ansicht toggle
shape-timeline.ts gains a third render mode triggered by lanes.length>1:
.smart-timeline-lanes-wrap holds a multiselect lane filter chip-row +
the .smart-timeline-lanes grid (one column per lane, time axis vertical
within each lane). Lanes the user has unchecked render dimmed to
preserve time-axis alignment across the strip; "Alle" pseudo-chip
resets to all selected. Lane mode takes precedence over Track-mode
(different axes — lanes group by direct-child project, tracks group
by CCR-vs-parent on a single Case).

loadTimeline parses the new envelope shape {events, lanes} from
GET /api/projects/{id}/timeline; defensive fallback to the old []
shape during the rolling deploy window. selectedLanes state is
client-side (chip toggles re-render in place without a re-fetch);
disappearing lanes (e.g. CCR child deleted between renders) drop
out of the selection automatically.

Client-level Verlauf toggle (Q12 lock-in): on project.type='client',
the Verlauf tab defaults to the matter-list rendering (simple list
of direct child litigations linking through). Flipping the
"Timeline-Ansicht" toggle (visible only at Client level) swaps to
the lane SmartTimeline. State persists in localStorage per project
so navigating away + back keeps the user's choice. Patent +
Litigation default to the lane view, matching Q12.

Custom-milestone form gains the bubble_up checkbox (§7.2 Q5). When
checked, the milestone surfaces on Patent / Litigation / Client
SmartTimelines via the backend's metadata.bubble_up=true override.
Default OFF for custom_milestone — structural milestones
(counterclaim_created etc.) default ON server-side.

CSS: ~130 lines under .smart-timeline-lanes / -lane / -lane-filter /
-matter-list. Mobile collapses lanes to single-column at ≤640px.

i18n: 12 new keys (DE+EN) under projects.detail.smarttimeline.lane.* /
.client.* / .milestone.bubble_up.

Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
2026-05-09 16:27:39 +02:00
m
7da8802f9b 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
2026-05-09 16:22:07 +02:00
m
91d3811276 Merge: t-paliad-174 — SmartTimeline Slice 3 (counterclaim sub-project + parallel-track render)
bohr's Slice 3 of the SmartTimeline per docs/design-smart-timeline-2026-05-08.md
§4 + §10. Counterclaims now first-class as sub-project rows with their own
proceeding type, our_side perspective, and timeline; parent's SmartTimeline
renders them as a parallel right-track on desktop + vertical-stacked sub-headers
on mobile.

Backend (commits 306bb11 + 82888de):
- Migration 077: paliad.projects.counterclaim_of nullable FK ON DELETE SET NULL,
  partial index, and a deferred trigger paliad.projects_no_two_level_ccr that
  rejects malformed two-level CCR-of-CCR chains at the schema level. Defense in
  depth — service-side ErrInvalidInput AND schema-side trigger.
- ProjectService.CreateCounterclaim: atomic create with parent-id placement
  (sibling under patent — child.parent_id = parent.parent_id, fallback to
  parent.id when parent has no parent), our_side flipped by default
  (claimant↔defendant; both stays both), proceeding_type defaults to UPC_REV,
  bilateral counterclaim_created audit rows on both parent + child.
- ProjectService.LoadCounterclaimChildrenVisible.
- ProjectionService.For loads CCR children for parent view; emits
  Track='counterclaim:<id>' rows. CCR child's view also loads parent context
  faded (Track='parent_context:<id>') per design §4.5. AvailableTracks
  surfaced via new X-Projection-Tracks response header.
- POST /api/projects/{id}/counterclaim handler.
- Tests: TestDerivedCounterclaimOurSide (9 cases) + TestCreateCounterclaim_Live
  (4 sub-tests).

Frontend (commit 483649d):
- shape-timeline.ts: CSS-grid wrapper renders one column per available track;
  ≤640px media query collapses to vertical stacking with sub-headers per
  track. [Track ▼] dropdown filters Beide / Nur Hauptverfahren /
  Nur Widerklage purely client-side (no re-fetch).
- '+ Eintrag → Widerklage (CCR)' inline form: proceeding-type select
  (UPC_REV default; UPC_CCI for R.49.2.b path), title + CCR case_number,
  'Stimmt nicht?' toggle for our_side override. POSTs and navigates to
  the new child's /projects/<id>.

Locked picks per design §11 (no deviations):
- Q1: counterclaim = sub-project
- Q2: default-flip our_side with toggle
- Q4: sibling-under-patent placement
- Q8: parallel right-track + Track chip + mobile-stack collapse

Verified: go build ./... clean, go vet clean, go test
./internal/services ./internal/handlers passing, bun build clean (2161
keys). Migration 077 dry-run on live DB succeeded + rolled back; tracker
advances 76 → 77 on next deploy boot.

Out of scope (Slice 4): lane-grouped rendering at Patent / Litigation /
Client levels; 'Timeline-Ansicht' Client toggle; off-script bubble-up.

Sequence enforcement (#31, Slice 2) keeps working independently per track
— anchoring SoD on parent rejects without parent's SoC, same for the CCR
chain on its own. Cross-track is correctly NOT enforced.
2026-05-09 16:09:24 +02:00
12 changed files with 1415 additions and 39 deletions

View File

@@ -1226,6 +1226,16 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.smarttimeline.counterclaim.flip_hint": "Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.",
"projects.detail.smarttimeline.counterclaim.submit": "Widerklage anlegen",
"projects.detail.smarttimeline.counterclaim.saving": "Lege Widerklage an …",
"projects.detail.smarttimeline.lane.empty": "Keine Einträge in dieser Spur.",
"projects.detail.smarttimeline.lane.filter.label": "Spuren",
"projects.detail.smarttimeline.lane.filter.all": "Alle",
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline-Ansicht",
"projects.detail.smarttimeline.client.toggle.matter_list": "Mandatsliste",
"projects.detail.smarttimeline.client.matter_list.heading": "Verfahren des Mandanten",
"projects.detail.smarttimeline.client.matter_list.hint": "Klicke ein Verfahren an, um die Detail-Timeline zu öffnen, oder schalte oben auf „Timeline-Ansicht“.",
"projects.detail.smarttimeline.client.matter_list.empty": "Noch keine Verfahren angelegt.",
"projects.detail.smarttimeline.milestone.bubble_up": "In übergeordneten Akten anzeigen",
"projects.detail.smarttimeline.milestone.bubble_up_hint": "Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.",
"projects.detail.team.form.user": "Benutzer",
"projects.detail.team.form.role": "Rolle",
"projects.detail.team.form.responsibility": "Rolle im Projekt",
@@ -3506,6 +3516,16 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.smarttimeline.counterclaim.flip_hint": "In the standard case (CCR on validity) our side flips (claimant ↔ defendant). Enable for the R.49.2.b CCI edge case.",
"projects.detail.smarttimeline.counterclaim.submit": "Create counterclaim",
"projects.detail.smarttimeline.counterclaim.saving": "Creating counterclaim…",
"projects.detail.smarttimeline.lane.empty": "No entries in this lane.",
"projects.detail.smarttimeline.lane.filter.label": "Lanes",
"projects.detail.smarttimeline.lane.filter.all": "All",
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline view",
"projects.detail.smarttimeline.client.toggle.matter_list": "Matter list",
"projects.detail.smarttimeline.client.matter_list.heading": "Matters of this client",
"projects.detail.smarttimeline.client.matter_list.hint": "Click a matter to open its detailed timeline, or switch to „Timeline view“ above.",
"projects.detail.smarttimeline.client.matter_list.empty": "No matters yet.",
"projects.detail.smarttimeline.milestone.bubble_up": "Show on parent matters",
"projects.detail.smarttimeline.milestone.bubble_up_hint": "When checked, this milestone surfaces on patent, litigation, and client SmartTimelines.",
"projects.detail.team.form.user": "User",
"projects.detail.team.form.role": "Role",
"projects.detail.team.form.responsibility": "Project role",

View File

@@ -11,7 +11,7 @@ import {
} from "./project-form";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent } from "./views/shape-timeline";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
interface Project {
id: string;
@@ -241,6 +241,20 @@ let timelineProjectedTotal = 0;
let timelineAvailableTracks: string[] = [];
let timelineSelectedTrack = "all";
// Slice 4 — parent-node lane aggregation (t-paliad-175). Lanes come
// from the response envelope's .lanes array. selectedLanes is the
// user's lane-filter state — null = "all selected" (the default);
// set explicitly when the user toggles a chip.
let timelineLanes: SmartTimelineLane[] = [];
let timelineSelectedLanes: string[] | null = null;
// Slice 4 — Client-level "Timeline-Ansicht" toggle. At Client-level
// project pages, the Verlauf tab defaults to the matter-list rendering
// (project tree); flipping the toggle swaps to the SmartTimeline lane
// view. State persists in localStorage per project so navigating away
// and back keeps the user's choice.
let timelineClientShowLanes = false;
// t-paliad-170 — Verlauf FilterBar state.
//
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
@@ -421,7 +435,19 @@ async function loadTimeline(id: string): Promise<void> {
try {
const resp = await fetch(url);
if (resp.ok) {
timelineRows = (await resp.json()) ?? [];
// Slice 4 (t-paliad-175) — wire shape changed from
// []TimelineEvent to envelope {events, lanes} so lane metadata
// can ride alongside the rows. Defensive parse: tolerate both
// shapes during the rolling deploy window (any cached older
// backend response is treated as events-only).
const body = await resp.json();
if (Array.isArray(body)) {
timelineRows = body;
timelineLanes = [];
} else {
timelineRows = (body?.events ?? []) as SmartTimelineEvent[];
timelineLanes = (body?.lanes ?? []) as SmartTimelineLane[];
}
// Pull projection meta from headers (Slice 2). When absent (e.g.
// proxy strips them), fall back to the visible projected count
// so "Mehr anzeigen" stays hidden — defensible default.
@@ -443,15 +469,26 @@ async function loadTimeline(id: string): Promise<void> {
if (timelineSelectedTrack !== "all" && !timelineAvailableTracks.includes(timelineSelectedTrack)) {
timelineSelectedTrack = "all";
}
// Drop selected lanes that disappeared between renders (e.g. a
// child case was deleted). null sentinel means "all" so leave it.
if (timelineSelectedLanes !== null) {
const laneIds = new Set(timelineLanes.map((l) => l.id));
timelineSelectedLanes = timelineSelectedLanes.filter((id) => laneIds.has(id));
if (timelineSelectedLanes.length === 0) {
timelineSelectedLanes = null;
}
}
} else {
timelineRows = [];
timelineProjectedTotal = 0;
timelineAvailableTracks = [];
timelineLanes = [];
}
} catch {
timelineRows = [];
timelineProjectedTotal = 0;
timelineAvailableTracks = [];
timelineLanes = [];
}
}
@@ -459,6 +496,15 @@ function renderTimeline() {
const host = document.getElementById("project-smart-timeline");
if (!host) return;
const projectId = project?.id;
// Slice 4 — Client-level Timeline-Ansicht toggle. At Client-level
// pages, the Verlauf default is the matter-list (project tree).
// Flipping the toggle swaps to the SmartTimeline lane view.
if (project?.type === "client" && !timelineClientShowLanes) {
renderClientMatterList(host);
return;
}
renderSmartTimeline(host, timelineRows, {
projectId,
lang: getLang() === "en" ? "en" : "de",
@@ -466,6 +512,15 @@ function renderTimeline() {
projectedTotal: timelineProjectedTotal,
availableTracks: timelineAvailableTracks,
selectedTrack: timelineSelectedTrack,
lanes: timelineLanes,
selectedLanes: timelineSelectedLanes ?? undefined,
onLaneFilterChange: async (next) => {
// Persist the explicit selection so a re-fetch doesn't reset it.
// Empty array = user unchecked everything → fall back to "all"
// so we never render a blank pane.
timelineSelectedLanes = next.length === 0 ? null : next;
renderTimeline();
},
onTrackChange: async (next) => {
timelineSelectedTrack = next;
// Track filter is purely client-side (rows are already loaded);
@@ -487,6 +542,60 @@ function renderTimeline() {
});
}
// renderClientMatterList renders the Client-level default Verlauf view
// — a simple list of direct child litigations with their reference and
// status. This stands in for the existing project-tree component when
// Timeline-Ansicht is OFF (the default at Client level per design §5.1
// + Q12). User can flip the Timeline-Ansicht toggle to see the lane
// SmartTimeline.
function renderClientMatterList(host: HTMLElement) {
host.innerHTML = "";
host.classList.add("smart-timeline");
const wrap = document.createElement("div");
wrap.className = "smart-timeline-matter-list";
const heading = document.createElement("h3");
heading.className = "smart-timeline-matter-list-heading";
heading.textContent = t("projects.detail.smarttimeline.client.matter_list.heading");
wrap.appendChild(heading);
const hint = document.createElement("p");
hint.className = "form-hint";
hint.textContent = t("projects.detail.smarttimeline.client.matter_list.hint");
wrap.appendChild(hint);
// The lane info from the backend already contains the direct child
// litigations (one entry per child). When empty, the message guides
// the user to add a litigation first.
if (timelineLanes.length === 0) {
const empty = document.createElement("p");
empty.className = "entity-events-empty";
empty.textContent = t("projects.detail.smarttimeline.client.matter_list.empty");
wrap.appendChild(empty);
host.appendChild(wrap);
return;
}
const list = document.createElement("ul");
list.className = "smart-timeline-matter-list-items";
for (const lane of timelineLanes) {
const li = document.createElement("li");
li.className = "smart-timeline-matter-list-item";
if (lane.project_id) {
const link = document.createElement("a");
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
link.textContent = lane.label;
li.appendChild(link);
} else {
li.textContent = lane.label;
}
list.appendChild(li);
}
wrap.appendChild(list);
host.appendChild(wrap);
}
function lookaheadStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.lookahead.${id}`;
@@ -1042,6 +1151,63 @@ function refreshAuditToggleLabel() {
btn.classList.toggle("subtree-toggle--active", timelineAuditFull);
}
// Slice 4 — Client-level "Timeline-Ansicht" toggle (t-paliad-175 §5.1
// Q12). Visible only on Client-level projects; default OFF (matter-list
// view). When ON, the SmartTimeline lane view replaces the matter list.
// State persists in localStorage per project.
function clientShowLanesStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.client_show_lanes.${id}`;
}
function readClientShowLanes(): boolean {
try {
return localStorage.getItem(clientShowLanesStorageKey()) === "1";
} catch {
return false;
}
}
function writeClientShowLanes(on: boolean) {
try {
if (on) localStorage.setItem(clientShowLanesStorageKey(), "1");
else localStorage.removeItem(clientShowLanesStorageKey());
} catch {
// ignore
}
}
function initSmartTimelineClientToggle(id: string) {
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
if (!btn) return;
// Toggle is markup-rendered always; hide on non-Client projects.
if (project?.type !== "client") {
btn.style.display = "none";
return;
}
btn.style.display = "";
timelineClientShowLanes = readClientShowLanes();
refreshClientToggleLabel();
btn.addEventListener("click", async () => {
timelineClientShowLanes = !timelineClientShowLanes;
writeClientShowLanes(timelineClientShowLanes);
refreshClientToggleLabel();
// Reload to make sure lanes are populated when flipping ON.
await loadTimeline(id);
renderTimeline();
});
}
function refreshClientToggleLabel() {
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", timelineClientShowLanes ? "true" : "false");
btn.textContent = timelineClientShowLanes
? t("projects.detail.smarttimeline.client.toggle.matter_list")
: t("projects.detail.smarttimeline.client.toggle.lanes");
btn.classList.toggle("subtree-toggle--active", timelineClientShowLanes);
}
// initSmartTimelineAddModal — wires the "+ Eintrag" CTA + modal. Only
// the "Eigener Meilenstein" route is fully wired in Slice 1 (writes
// to /api/projects/{id}/timeline/milestone); Frist + Termin are link
@@ -1114,6 +1280,11 @@ function initSmartTimelineAddModal(id: string) {
if (desc) payload.description = desc;
const date = dateInput?.value;
if (date) payload.occurred_at = date;
// Slice 4 — bubble-up checkbox (t-paliad-175 §7.2 Q5). Default OFF
// for custom_milestone; user opts in to surface this milestone on
// Patent / Litigation / Client SmartTimelines.
const bubbleEl = document.getElementById("smart-timeline-milestone-bubble-up") as HTMLInputElement | null;
if (bubbleEl?.checked) payload.bubble_up = true;
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
@@ -1788,6 +1959,7 @@ async function main() {
initEventsLoadMore();
initSubtreeToggles(id);
initSmartTimelineAuditToggle(id);
initSmartTimelineClientToggle(id);
initSmartTimelineAddModal(id);
initAttachUnitForm(id);
initNotesContainer(id);

View File

@@ -1,6 +1,6 @@
import { t, getLang } from "../i18n";
// shape-timeline (t-paliad-171 + t-paliad-173) — vertical timeline render
// shape-timeline (t-paliad-171 t-paliad-175) — vertical timeline render
// for the SmartTimeline. Two-column layout (date / event card), "Heute →"
// rule separating past from future, status icon + kind chip per row.
//
@@ -19,10 +19,22 @@ import { t, getLang } from "../i18n";
// - "[+ Mehr anzeigen]" / "[ Weniger]" lookahead toggle after the 7th
// projected row, cap remembered in localStorage per project.
//
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
// shape is the wire contract from /api/projects/{id}/timeline.
// Slice 4 (t-paliad-175) adds parent-node lane aggregation:
// - When `lanes.length > 1` (Patent / Litigation / Client view), render
// a horizontal lane-strip with one column per lane. Time axis stays
// vertical within each lane; the lane sub-header names the child
// project. CSS Grid handles the desktop side-by-side and collapses
// to single-column on mobile (≤640px).
// - Lane filter chip (multiselect) sits in the timeline header above
// the strip; selecting a subset dims the others.
// - Single-column flow stays the default at Case level (lanes mirror
// tracks one-for-one).
//
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §6 +
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
// shape is the wire contract from /api/projects/{id}/timeline.events;
// LaneInfo[] from .lanes drives the lane-grouped layout.
//
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §5 + §6 +
// m/paliad#31 layered requirements.
export interface TimelineEvent {
@@ -54,6 +66,19 @@ export interface TimelineEvent {
depends_on_rule_code?: string;
depends_on_date?: string | null;
depends_on_rule_name?: string;
// Slice 4 — parent-node aggregation (t-paliad-175). lane_id buckets
// the row into one of the columns described by RenderOptions.lanes.
// Empty / missing is treated as "self" (the legacy single-lane case).
lane_id?: string;
bubble_up?: boolean;
}
export interface LaneInfo {
id: string;
label: string;
project_id?: string;
primary?: boolean;
}
export interface PredecessorMissingPayload {
@@ -97,6 +122,16 @@ export interface RenderOptions {
availableTracks?: string[];
selectedTrack?: string;
onTrackChange?: (next: string) => void | Promise<void>;
// Slice 4 — parent-node lane aggregation. When lanes.length > 1,
// renderSmartTimeline renders a lane-strip layout (one column per
// lane) instead of the single-column flow. selectedLanes is the
// user's lane-filter chip; defaults to all lanes selected. Empty
// array = nothing rendered (defensible for the user explicitly
// unchecking every lane).
lanes?: LaneInfo[];
selectedLanes?: string[]; // ids; undefined = all lanes selected
onLaneFilterChange?: (next: string[]) => void | Promise<void>;
}
export function renderSmartTimeline(
@@ -107,6 +142,19 @@ export function renderSmartTimeline(
host.innerHTML = "";
host.classList.add("smart-timeline");
// Slice 4 — lane-grouped rendering (t-paliad-175 §5). When the
// backend reports more than one lane, every event already carries a
// lane_id and the layout switches from single-column to lane strip.
// Lane mode takes precedence over Track-mode (the two are different
// axes — lanes group by *direct child project*, tracks group by
// CCR-vs-parent on a single Case).
const lanes = opts.lanes ?? [];
const isLaneMode = lanes.length > 1;
if (isLaneMode) {
host.appendChild(renderLaneStrip(rows, lanes, opts));
return;
}
// Slice 3 — track filtering. The bar header carries the [Track ▼]
// chip whenever the response advertised more than the default
// "parent" track; the filter is applied here before any flow render.
@@ -145,6 +193,129 @@ export function renderSmartTimeline(
host.appendChild(renderTimelineFlow(filteredRows, opts));
}
// renderLaneStrip builds the parent-node aggregated layout (Slice 4).
// One column per lane, each column shows the lane's own past/today/
// future flow. Lane filter chip (multiselect) sits above the strip.
// Lanes the user has unchecked render dimmed but still take up the
// column slot — this preserves the time-axis alignment across lanes.
function renderLaneStrip(
rows: TimelineEvent[],
lanes: LaneInfo[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lanes-wrap";
// Lane filter chip (Slice 4) — multiselect with "alle" / "keine".
// Sits above the strip.
wrap.appendChild(renderLaneFilterChip(lanes, opts));
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
const grid = document.createElement("div");
grid.className = "smart-timeline-lanes";
grid.style.setProperty("--smart-timeline-lane-count", String(lanes.length));
// Group rows by lane_id. Rows without a lane_id default to the first
// lane id so they don't disappear. For lane mode the backend always
// sets lane_id explicitly; this fallback is defensive.
const byLane = new Map<string, TimelineEvent[]>();
for (const l of lanes) byLane.set(l.id, []);
for (const r of rows) {
const id = r.lane_id || lanes[0].id;
if (!byLane.has(id)) byLane.set(id, []);
byLane.get(id)!.push(r);
}
for (const lane of lanes) {
const col = document.createElement("div");
col.className = "smart-timeline-lane";
if (!selected.has(lane.id)) {
col.classList.add("smart-timeline-lane--dimmed");
}
if (lane.primary) {
col.classList.add("smart-timeline-lane--primary");
}
const header = document.createElement("h4");
header.className = "smart-timeline-lane-header";
if (lane.project_id) {
const link = document.createElement("a");
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
link.textContent = lane.label;
link.className = "smart-timeline-lane-header-link";
header.appendChild(link);
} else {
header.textContent = lane.label;
}
col.appendChild(header);
const laneRows = byLane.get(lane.id) ?? [];
if (laneRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-lane-empty";
empty.textContent = t("projects.detail.smarttimeline.lane.empty");
col.appendChild(empty);
} else {
col.appendChild(renderTimelineFlow(laneRows, opts));
}
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
// renderLaneFilterChip — multiselect chip-row for the lane filter.
// Defaults to all lanes selected; user toggles individual chips. The
// "Alle" pseudo-chip resets to all selected.
function renderLaneFilterChip(
lanes: LaneInfo[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lane-filter";
const label = document.createElement("span");
label.className = "smart-timeline-lane-filter-label";
label.textContent = t("projects.detail.smarttimeline.lane.filter.label");
wrap.appendChild(label);
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.className = "smart-timeline-lane-chip smart-timeline-lane-chip--all";
if (selected.size === lanes.length) {
allBtn.classList.add("is-active");
}
allBtn.textContent = t("projects.detail.smarttimeline.lane.filter.all");
allBtn.addEventListener("click", () => {
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(lanes.map((l) => l.id));
});
wrap.appendChild(allBtn);
for (const lane of lanes) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "smart-timeline-lane-chip";
if (selected.has(lane.id)) chip.classList.add("is-active");
chip.textContent = lane.label;
chip.addEventListener("click", () => {
const next = new Set(selected);
if (next.has(lane.id)) {
next.delete(lane.id);
} else {
next.add(lane.id);
}
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(Array.from(next));
});
wrap.appendChild(chip);
}
return wrap;
}
// renderParallelTracks builds a CSS-grid wrapper with one column per
// track. Each column is a self-contained smart-timeline-flow with its
// own past / today / future sections, plus a sub-header that names the

View File

@@ -1714,6 +1714,11 @@ export type I18nKey =
| "projects.detail.smarttimeline.anchor.set"
| "projects.detail.smarttimeline.audit.toggle.hide"
| "projects.detail.smarttimeline.audit.toggle.show"
| "projects.detail.smarttimeline.client.matter_list.empty"
| "projects.detail.smarttimeline.client.matter_list.heading"
| "projects.detail.smarttimeline.client.matter_list.hint"
| "projects.detail.smarttimeline.client.toggle.lanes"
| "projects.detail.smarttimeline.client.toggle.matter_list"
| "projects.detail.smarttimeline.counterclaim.case_number"
| "projects.detail.smarttimeline.counterclaim.flip_hint"
| "projects.detail.smarttimeline.counterclaim.flip_override"
@@ -1733,8 +1738,13 @@ export type I18nKey =
| "projects.detail.smarttimeline.kind.deadline"
| "projects.detail.smarttimeline.kind.milestone"
| "projects.detail.smarttimeline.kind.projected"
| "projects.detail.smarttimeline.lane.empty"
| "projects.detail.smarttimeline.lane.filter.all"
| "projects.detail.smarttimeline.lane.filter.label"
| "projects.detail.smarttimeline.lookahead.less"
| "projects.detail.smarttimeline.lookahead.more"
| "projects.detail.smarttimeline.milestone.bubble_up"
| "projects.detail.smarttimeline.milestone.bubble_up_hint"
| "projects.detail.smarttimeline.milestone.date"
| "projects.detail.smarttimeline.milestone.description"
| "projects.detail.smarttimeline.milestone.title"

View File

@@ -95,6 +95,12 @@ export function renderProjectsDetail(): string {
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
Audit-Log anzeigen
</button>
{/* Slice 4 — Client-level Timeline-Ansicht toggle.
Hidden by default (display:none); the client TS
flips it visible only when project.type === 'client'. */}
<button type="button" className="btn-secondary btn-small" id="smart-timeline-client-toggle" aria-pressed="false" style="display:none" data-i18n="projects.detail.smarttimeline.client.toggle.lanes">
Timeline-Ansicht
</button>
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
+ Eintrag
</button>
@@ -142,6 +148,16 @@ export function renderProjectsDetail(): string {
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
<textarea id="smart-timeline-milestone-desc" rows={3} />
</div>
{/* Slice 4 — bubble-up override (t-paliad-175 §7.2 Q5). */}
<div className="form-field form-field--checkbox">
<label className="form-checkbox">
<input type="checkbox" id="smart-timeline-milestone-bubble-up" />
<span data-i18n="projects.detail.smarttimeline.milestone.bubble_up">In übergeordneten Akten anzeigen</span>
</label>
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.milestone.bubble_up_hint">
Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.
</small>
</div>
<div id="smart-timeline-milestone-msg" className="form-msg" />
<div className="form-field-row">
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>

View File

@@ -14023,6 +14023,137 @@ dialog.quick-add-sheet::backdrop {
outline-offset: 1px;
}
/* SmartTimeline Slice 4 — parent-node lane aggregation (t-paliad-175).
.smart-timeline-lanes is the grid wrapper; .smart-timeline-lane is
each direct-child column. Layout mirrors .smart-timeline-tracks but
carries its own modifier so the visual treatment can diverge as the
product evolves (lane widths can become richer with sub-headers).
Mobile collapse to single-column at ≤640px. */
.smart-timeline-lanes-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.smart-timeline-lanes {
display: grid;
grid-template-columns: repeat(var(--smart-timeline-lane-count, 2), minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.smart-timeline-lane {
border: 1px solid var(--color-border, #e0e0e0);
border-radius: 8px;
padding: 0.75rem 0.85rem 1rem;
background: var(--color-surface, #fff);
min-width: 0;
transition: opacity 120ms ease-out;
}
.smart-timeline-lane--primary {
border-left: 3px solid var(--color-accent, #c6f41c);
}
.smart-timeline-lane--dimmed {
opacity: 0.35;
}
.smart-timeline-lane-header {
margin: 0 0 0.5rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-muted, #555);
border-bottom: 1px solid var(--color-border, #e0e0e0);
padding-bottom: 0.4rem;
}
.smart-timeline-lane-header-link {
color: inherit;
text-decoration: none;
}
.smart-timeline-lane-header-link:hover {
color: var(--color-link, #1a8aff);
text-decoration: underline;
}
.smart-timeline-lane-empty {
color: var(--color-text-muted, #888);
font-size: 0.85rem;
padding: 0.75rem 0;
font-style: italic;
}
@media (max-width: 640px) {
.smart-timeline-lanes {
grid-template-columns: 1fr;
}
}
/* Lane filter chip-row — multiselect chips above the strip. Mirrors the
FilterBar chip pattern; "Alle" pseudo-chip is highlighted when every
lane is selected. */
.smart-timeline-lane-filter {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin: 0.25rem 0 0.5rem;
}
.smart-timeline-lane-filter-label {
font-size: 0.85rem;
color: var(--color-text-muted, #555);
font-weight: 500;
}
.smart-timeline-lane-chip {
border: 1px solid var(--color-border, #ccc);
background: var(--color-surface, #fff);
color: var(--color-text, #222);
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
transition: background 120ms ease-out, border-color 120ms ease-out;
}
.smart-timeline-lane-chip:hover {
border-color: var(--color-accent, #c6f41c);
}
.smart-timeline-lane-chip.is-active {
background: var(--color-bg-lime-tint, #f4fdd1);
border-color: var(--color-accent, #c6f41c);
}
.smart-timeline-lane-chip--all {
font-weight: 500;
}
/* Client-level matter-list (Slice 4 default at type=client). Simple
list, slot for each direct child litigation. The Timeline-Ansicht
toggle in the Verlauf controls flips between this and the lane view. */
.smart-timeline-matter-list {
border: 1px solid var(--color-border, #e0e0e0);
border-radius: 8px;
padding: 1rem 1.25rem;
background: var(--color-surface, #fff);
}
.smart-timeline-matter-list-heading {
margin: 0 0 0.4rem;
font-size: 1rem;
font-weight: 600;
}
.smart-timeline-matter-list-items {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.smart-timeline-matter-list-item {
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: var(--color-surface-2, #f7f7f7);
}
.smart-timeline-matter-list-item a {
color: inherit;
text-decoration: none;
display: block;
}
.smart-timeline-matter-list-item a:hover {
text-decoration: underline;
}
/* Counterclaim form layout follow-ups — inherits .entity-form, just
tightens the optional checkbox row + hint. */
.form-field--checkbox {

View File

@@ -68,9 +68,13 @@ func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
if rows == nil {
rows = []services.TimelineEvent{}
}
// Surface projection meta via headers so the wire shape stays
// []TimelineEvent (frozen since Slice 1) while the frontend can
// detect "Mehr anzeigen" availability.
lanes := meta.Lanes
if lanes == nil {
lanes = []services.LaneInfo{}
}
// Surface projection meta via headers — Slice 1-3 frontends still
// read X-Projection-Total / Lookahead / Tracks for the lookahead
// toggle and Track chip.
w.Header().Set("X-Projection-Has", boolStr(meta.HasProjection))
w.Header().Set("X-Projection-Total", itoa(meta.ProjectedTotal))
w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown))
@@ -81,7 +85,15 @@ func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
// "parent_context:<id>"). Track ids are UUIDs — safe in headers.
w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ","))
}
writeJSON(w, http.StatusOK, rows)
// Slice 4 changed the wire shape from []TimelineEvent to an envelope
// {events, lanes} so lane metadata can ride alongside the rows
// without exceeding header-size limits when a Client-level
// projection has many lanes. The frontend reads .events for the
// per-row contract and .lanes for parallel-column rendering.
writeJSON(w, http.StatusOK, services.ResponseEnvelope{
Events: rows,
Lanes: lanes,
})
}
// POST /api/projects/{id}/timeline/anchor
@@ -352,6 +364,7 @@ func handleCreateProjectTimelineMilestone(w http.ResponseWriter, r *http.Request
Title string `json:"title"`
Description *string `json:"description,omitempty"`
OccurredAt *string `json:"occurred_at,omitempty"`
BubbleUp bool `json:"bubble_up,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
@@ -371,7 +384,7 @@ func handleCreateProjectTimelineMilestone(w http.ResponseWriter, r *http.Request
}
ev, err := dbSvc.projection.RecordCustomMilestone(r.Context(), uid, id,
body.Title, body.Description, occurred)
body.Title, body.Description, occurred, body.BubbleUp)
if err != nil {
writeServiceError(w, err)
return

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.