Compare commits
3 Commits
mai/bohr/s
...
mai/schroe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e57507a92 | ||
|
|
7da8802f9b | ||
|
|
91d3811276 |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
271
internal/services/projection_levels_test.go
Normal file
271
internal/services/projection_levels_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user