Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09: m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind chips changed URL params but the rendered list never narrowed. Two causes: (1) the Verlauf bar mounted only "time" + "project_event_kind" axes — the timeline_status / timeline_track chips never appeared. (2) the customRunner drained predicates into `loadEvents` which writes the legacy `events` array; the SmartTimeline render reads `timelineRows`, so the filter pass was a dead branch. Fix: mount all three axes on the bar; rewrite customRunner to drain state into `verlaufFilters`; renderTimeline applies them client-side via `applyTimelineRowFilters` before handing rows to renderSmartTimeline. project_event_kind is forwarded through the substrate-shaped predicate map (effective.filter.predicates.project_event.event_types); timeline_status / timeline_track sit on raw BarState — the customRunner signature now accepts the BarState snapshot as a second arg so the bar's first run (before the handle is assigned) can read them. Backend adds `ProjectEventType` to TimelineEvent + frontend TimelineEvent — needed so the project_event_kind chip can match against the underlying paliad.project_events.event_type for milestone rows. m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the timeline with ?direct_only=true, but ProjectionService.For honoured the flag only at the deadline / appointment / project_events SQL level. CCR sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded unconditionally, so the "direct" view still showed everything. Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is set. Single "self" lane, no CCR / parent_context / child-case aggregation. The level-policy kind/status filter still applies at higher levels so a Patent-level direct view doesn't leak off_script custom milestones the aggregated view filters out. Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live pin the contract — Patent direct_only collapses to a single 'self' lane and excludes child-case events; Case-A direct_only excludes the CCR child's milestones (with subtree default still surfacing them). Build: go build/vet/test clean. bun run build clean (2171 keys).
967 lines
35 KiB
TypeScript
967 lines
35 KiB
TypeScript
import { t, getLang } from "../i18n";
|
||
|
||
// 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.
|
||
//
|
||
// Slice 2 (t-paliad-173) adds:
|
||
// - Kind="projected" rows in three flavours via Status:
|
||
// "predicted" — fade-grey (future)
|
||
// "court_set" — dashed border (court-determined)
|
||
// "predicted_overdue" — amber-faded (past, no anchor yet)
|
||
// - "[Datum setzen]" inline date editor → POST /timeline/anchor.
|
||
// 200 → re-fetch + re-render. 409 → render the predecessor_missing
|
||
// payload as inline error with a "Stattdessen <predecessor> erfassen"
|
||
// link that pre-fills the editor for the parent rule.
|
||
// - "Folgt aus: <Name> (<Date|„Datum offen“>)" footer on every row
|
||
// with depends_on_rule_code, plus a "[Pfad anzeigen]" expander that
|
||
// walks the parent chain back to the trigger.
|
||
// - "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle after the 7th
|
||
// projected row, cap remembered in localStorage per project.
|
||
//
|
||
// 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).
|
||
//
|
||
// 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 {
|
||
kind: "deadline" | "appointment" | "milestone" | "projected";
|
||
status:
|
||
| "done"
|
||
| "open"
|
||
| "overdue"
|
||
| "court_set"
|
||
| "predicted"
|
||
| "predicted_overdue"
|
||
| "off_script";
|
||
track: string;
|
||
date?: string | null;
|
||
title: string;
|
||
description?: string;
|
||
rule_code?: string;
|
||
|
||
deadline_id?: string;
|
||
appointment_id?: string;
|
||
project_event_id?: string;
|
||
|
||
deadline_rule_id?: string;
|
||
deadline_rule_party?: string;
|
||
|
||
sub_project_id?: string;
|
||
sub_project_title?: string;
|
||
|
||
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;
|
||
|
||
// t-paliad-176 — underlying paliad.project_events.event_type for
|
||
// milestone rows. Empty for deadline / appointment / projected rows.
|
||
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
|
||
// (matched against KnownProjectEventKinds in filter_spec.go).
|
||
project_event_type?: string;
|
||
}
|
||
|
||
export interface LaneInfo {
|
||
id: string;
|
||
label: string;
|
||
project_id?: string;
|
||
primary?: boolean;
|
||
}
|
||
|
||
export interface PredecessorMissingPayload {
|
||
error: "predecessor_missing";
|
||
missing_rule_code: string;
|
||
missing_rule_name_de: string;
|
||
missing_rule_name_en: string;
|
||
requested_rule_code: string;
|
||
requested_rule_name_de: string;
|
||
requested_rule_name_en: string;
|
||
message_de: string;
|
||
message_en: string;
|
||
}
|
||
|
||
export interface RenderOptions {
|
||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||
today?: string;
|
||
// The project the timeline belongs to. Required for anchor / skip
|
||
// POSTs. When undefined, projected rows don't expose "Datum setzen".
|
||
projectId?: string;
|
||
// Language hint — falls back to getLang() when omitted.
|
||
lang?: "de" | "en";
|
||
// Called after a successful anchor write so the host can re-fetch
|
||
// and re-render. Skipped when omitted.
|
||
onChange?: () => void | Promise<void>;
|
||
// Lookahead state for projected rows. Default 7 = backend default.
|
||
lookahead?: number;
|
||
// Total number of future predicted rows the backend knows about
|
||
// (read from X-Projection-Total). When > visible projected count,
|
||
// "Mehr anzeigen" is shown.
|
||
projectedTotal?: number;
|
||
// Called when the user toggles "Mehr / Weniger anzeigen". The host
|
||
// updates state + re-fetches with the new ?lookahead=N.
|
||
onLookaheadChange?: (next: number) => void | Promise<void>;
|
||
|
||
// Slice 3 — counterclaim parallel tracks. availableTracks lists every
|
||
// track tag present in the response (parsed from X-Projection-Tracks).
|
||
// When the list contains a non-"parent" entry, the [Track ▼] chip
|
||
// surfaces. selectedTrack is the user's filter ("all" = render every
|
||
// available track in parallel; otherwise render only the named tag).
|
||
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(
|
||
host: HTMLElement,
|
||
rows: TimelineEvent[],
|
||
opts: RenderOptions = {},
|
||
): void {
|
||
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.
|
||
const availableTracks = (opts.availableTracks ?? []).filter((t) => !!t);
|
||
const hasMultipleTracks = availableTracks.length > 1;
|
||
const selectedTrack = opts.selectedTrack ?? "all";
|
||
if (hasMultipleTracks) {
|
||
host.appendChild(renderTrackChip(availableTracks, selectedTrack, opts));
|
||
}
|
||
|
||
// Filter rows by the selected track. "all" leaves rows untouched
|
||
// (parallel layout decides per-track partitioning below).
|
||
const filteredRows =
|
||
selectedTrack === "all"
|
||
? rows
|
||
: rows.filter((r) => (r.track ?? "parent") === selectedTrack);
|
||
|
||
if (filteredRows.length === 0) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "smart-timeline-empty";
|
||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||
host.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
// When the user has selected "all" AND there are multiple tracks
|
||
// present, render parallel columns side-by-side. Otherwise the
|
||
// existing single-column flow serves both single-track projects and
|
||
// an explicit "Nur Hauptverfahren / Nur Widerklage" filter.
|
||
if (selectedTrack === "all" && hasMultipleTracks) {
|
||
host.appendChild(renderParallelTracks(filteredRows, availableTracks, opts));
|
||
return;
|
||
}
|
||
|
||
// Single-column flow.
|
||
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
|
||
// track ("Hauptverfahren" / "Widerklage — <CCR title>" / "Hauptverfahren
|
||
// (Kontext)" for the parent_context view on a CCR child).
|
||
//
|
||
// Mobile collapse (≤640px) is owned by CSS via .smart-timeline-tracks
|
||
// and a media query — the grid switches to a single column there with
|
||
// each sub-header preserved so the user knows which track they're on.
|
||
function renderParallelTracks(
|
||
rows: TimelineEvent[],
|
||
availableTracks: string[],
|
||
opts: RenderOptions,
|
||
): HTMLElement {
|
||
const grid = document.createElement("div");
|
||
grid.className = "smart-timeline-tracks";
|
||
grid.style.setProperty("--smart-timeline-track-count", String(availableTracks.length));
|
||
|
||
// Group rows by track. Rows with no track default to "parent".
|
||
const byTrack = new Map<string, TimelineEvent[]>();
|
||
for (const tr of availableTracks) byTrack.set(tr, []);
|
||
for (const r of rows) {
|
||
const key = r.track && byTrack.has(r.track) ? r.track : "parent";
|
||
if (!byTrack.has(key)) byTrack.set(key, []);
|
||
byTrack.get(key)!.push(r);
|
||
}
|
||
|
||
for (const trackTag of availableTracks) {
|
||
const trackRows = byTrack.get(trackTag) ?? [];
|
||
const col = document.createElement("div");
|
||
col.className = `smart-timeline-track ${trackClassFor(trackTag)}`;
|
||
|
||
const header = document.createElement("h4");
|
||
header.className = "smart-timeline-track-header";
|
||
header.textContent = trackHeaderLabel(trackTag, trackRows);
|
||
col.appendChild(header);
|
||
|
||
if (trackRows.length === 0) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "smart-timeline-track-empty";
|
||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||
col.appendChild(empty);
|
||
} else {
|
||
col.appendChild(renderTimelineFlow(trackRows, opts));
|
||
}
|
||
grid.appendChild(col);
|
||
}
|
||
|
||
return grid;
|
||
}
|
||
|
||
// renderTimelineFlow renders the past / today / future / undated flow
|
||
// for the given row set into a fresh container. Extracted from the
|
||
// pre-Slice-3 renderSmartTimeline so it can be reused as a per-track
|
||
// column in the parallel layout.
|
||
function renderTimelineFlow(rows: TimelineEvent[], opts: RenderOptions): HTMLElement {
|
||
const todayISO = opts.today ?? todayLocalISO();
|
||
const past: TimelineEvent[] = [];
|
||
const todays: TimelineEvent[] = [];
|
||
const future: TimelineEvent[] = [];
|
||
const undated: TimelineEvent[] = [];
|
||
for (const r of rows) {
|
||
const iso = dateOnlyISO(r.date);
|
||
if (!iso) {
|
||
undated.push(r);
|
||
continue;
|
||
}
|
||
if (iso < todayISO) past.push(r);
|
||
else if (iso === todayISO) todays.push(r);
|
||
else future.push(r);
|
||
}
|
||
past.sort(byDateAsc);
|
||
todays.sort(byDateAsc);
|
||
future.sort(byDateAsc);
|
||
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "smart-timeline-flow";
|
||
|
||
if (past.length > 0) {
|
||
const section = document.createElement("section");
|
||
section.className = "smart-timeline-section smart-timeline-section--past";
|
||
const heading = document.createElement("h3");
|
||
heading.className = "smart-timeline-heading";
|
||
heading.textContent = t("projects.detail.smarttimeline.section.past");
|
||
section.appendChild(heading);
|
||
for (const ev of past) section.appendChild(renderRow(ev, opts));
|
||
wrap.appendChild(section);
|
||
}
|
||
|
||
const todayRule = document.createElement("div");
|
||
todayRule.className = "smart-timeline-today-rule";
|
||
const todayLabel = document.createElement("span");
|
||
todayLabel.className = "smart-timeline-today-label";
|
||
todayLabel.textContent = `${t("projects.detail.smarttimeline.today")} (${formatDateOnly(todayISO)})`;
|
||
todayRule.appendChild(todayLabel);
|
||
wrap.appendChild(todayRule);
|
||
|
||
if (todays.length > 0) {
|
||
const section = document.createElement("section");
|
||
section.className = "smart-timeline-section smart-timeline-section--today";
|
||
for (const ev of todays) section.appendChild(renderRow(ev, opts));
|
||
wrap.appendChild(section);
|
||
}
|
||
|
||
if (future.length > 0) {
|
||
const section = document.createElement("section");
|
||
section.className = "smart-timeline-section smart-timeline-section--future";
|
||
const heading = document.createElement("h3");
|
||
heading.className = "smart-timeline-heading";
|
||
heading.textContent = t("projects.detail.smarttimeline.section.future");
|
||
section.appendChild(heading);
|
||
for (const ev of future) section.appendChild(renderRow(ev, opts));
|
||
section.appendChild(renderLookaheadToggle(future, opts));
|
||
wrap.appendChild(section);
|
||
} else {
|
||
const lookaheadHost = renderLookaheadToggle(future, opts);
|
||
if (lookaheadHost.childElementCount > 0) {
|
||
wrap.appendChild(lookaheadHost);
|
||
}
|
||
}
|
||
|
||
if (undated.length > 0) {
|
||
const section = document.createElement("section");
|
||
section.className = "smart-timeline-section smart-timeline-section--undated";
|
||
const heading = document.createElement("h3");
|
||
heading.className = "smart-timeline-heading";
|
||
heading.textContent = t("projects.detail.smarttimeline.section.undated");
|
||
section.appendChild(heading);
|
||
for (const ev of undated) section.appendChild(renderRow(ev, opts));
|
||
wrap.appendChild(section);
|
||
}
|
||
|
||
return wrap;
|
||
}
|
||
|
||
// renderTrackChip builds the [Track ▼] selector. Options are derived
|
||
// from the response's available_tracks header — i18n keys translate
|
||
// each option label, with the sub-project title surfacing for CCR
|
||
// tracks ("Widerklage — <title>"). Persists the user's selection via
|
||
// the host through opts.onTrackChange.
|
||
function renderTrackChip(
|
||
availableTracks: string[],
|
||
selected: string,
|
||
opts: RenderOptions,
|
||
): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "smart-timeline-track-chip";
|
||
|
||
const label = document.createElement("label");
|
||
label.className = "smart-timeline-track-chip-label";
|
||
label.textContent = t("projects.detail.smarttimeline.track.label");
|
||
wrap.appendChild(label);
|
||
|
||
const select = document.createElement("select");
|
||
select.className = "smart-timeline-track-chip-select";
|
||
|
||
const allOpt = document.createElement("option");
|
||
allOpt.value = "all";
|
||
allOpt.textContent = t("projects.detail.smarttimeline.track.both");
|
||
select.appendChild(allOpt);
|
||
|
||
for (const trackTag of availableTracks) {
|
||
const opt = document.createElement("option");
|
||
opt.value = trackTag;
|
||
opt.textContent = trackOnlyLabel(trackTag);
|
||
select.appendChild(opt);
|
||
}
|
||
|
||
select.value = selected;
|
||
select.addEventListener("change", () => {
|
||
if (opts.onTrackChange) void opts.onTrackChange(select.value);
|
||
});
|
||
wrap.appendChild(select);
|
||
return wrap;
|
||
}
|
||
|
||
// trackClassFor maps a track tag to its CSS modifier so the column
|
||
// gets the appropriate visual treatment (lime for parent, light shade
|
||
// for counterclaim, faded for parent_context).
|
||
function trackClassFor(trackTag: string): string {
|
||
if (trackTag === "parent") return "smart-timeline-track--parent";
|
||
if (trackTag.startsWith("counterclaim:")) return "smart-timeline-track--counterclaim";
|
||
if (trackTag.startsWith("parent_context:")) return "smart-timeline-track--parent-context";
|
||
return "smart-timeline-track--other";
|
||
}
|
||
|
||
// trackHeaderLabel picks the column sub-header. For CCR tracks pulls
|
||
// the sub_project_title from the first row in the track so the user
|
||
// sees "Widerklage — <child title>". Falls back to a generic label
|
||
// when the title is empty.
|
||
function trackHeaderLabel(trackTag: string, rows: TimelineEvent[]): string {
|
||
if (trackTag === "parent") {
|
||
return t("projects.detail.smarttimeline.track.header.parent");
|
||
}
|
||
const firstWithTitle = rows.find((r) => r.sub_project_title);
|
||
const subTitle = firstWithTitle?.sub_project_title ?? "";
|
||
if (trackTag.startsWith("counterclaim:")) {
|
||
const base = t("projects.detail.smarttimeline.track.header.counterclaim");
|
||
return subTitle ? `${base} — ${subTitle}` : base;
|
||
}
|
||
if (trackTag.startsWith("parent_context:")) {
|
||
const base = t("projects.detail.smarttimeline.track.header.parent_context");
|
||
return subTitle ? `${base} — ${subTitle}` : base;
|
||
}
|
||
return trackTag;
|
||
}
|
||
|
||
// trackOnlyLabel is the chip dropdown label for "show only this track".
|
||
function trackOnlyLabel(trackTag: string): string {
|
||
if (trackTag === "parent") {
|
||
return t("projects.detail.smarttimeline.track.only.parent");
|
||
}
|
||
if (trackTag.startsWith("counterclaim:")) {
|
||
return t("projects.detail.smarttimeline.track.only.counterclaim");
|
||
}
|
||
if (trackTag.startsWith("parent_context:")) {
|
||
return t("projects.detail.smarttimeline.track.only.parent_context");
|
||
}
|
||
return trackTag;
|
||
}
|
||
|
||
function renderLookaheadToggle(
|
||
futureRows: TimelineEvent[],
|
||
opts: RenderOptions,
|
||
): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "smart-timeline-lookahead";
|
||
const total = opts.projectedTotal ?? 0;
|
||
const projectedShown = futureRows.filter((r) => r.kind === "projected").length;
|
||
const cur = opts.lookahead ?? 7;
|
||
|
||
if (total > projectedShown && opts.onLookaheadChange) {
|
||
const more = document.createElement("button");
|
||
more.type = "button";
|
||
more.className = "smart-timeline-lookahead-btn";
|
||
more.textContent = t("projects.detail.smarttimeline.lookahead.more");
|
||
more.setAttribute(
|
||
"aria-label",
|
||
`${t("projects.detail.smarttimeline.lookahead.more")} (${total - projectedShown})`,
|
||
);
|
||
more.addEventListener("click", () => {
|
||
const next = Math.min(50, cur + 7);
|
||
void opts.onLookaheadChange?.(next);
|
||
});
|
||
wrap.appendChild(more);
|
||
}
|
||
if (cur > 7 && opts.onLookaheadChange) {
|
||
const less = document.createElement("button");
|
||
less.type = "button";
|
||
less.className = "smart-timeline-lookahead-btn smart-timeline-lookahead-btn--less";
|
||
less.textContent = t("projects.detail.smarttimeline.lookahead.less");
|
||
less.addEventListener("click", () => {
|
||
void opts.onLookaheadChange?.(7);
|
||
});
|
||
wrap.appendChild(less);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
function renderRow(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||
const li = document.createElement("article");
|
||
li.className =
|
||
`smart-timeline-row smart-timeline-row--${ev.kind} ` +
|
||
`smart-timeline-row--${ev.status}`;
|
||
if (ev.deadline_rule_party) {
|
||
li.classList.add(`smart-timeline-row--party-${ev.deadline_rule_party}`);
|
||
}
|
||
|
||
const dateCol = document.createElement("div");
|
||
dateCol.className = "smart-timeline-date";
|
||
dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—";
|
||
li.appendChild(dateCol);
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "smart-timeline-body";
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "smart-timeline-row-head";
|
||
|
||
const icon = document.createElement("span");
|
||
icon.className = "smart-timeline-status-icon";
|
||
icon.textContent = statusGlyph(ev.status);
|
||
icon.setAttribute("aria-label", t(statusKey(ev.status)));
|
||
head.appendChild(icon);
|
||
|
||
const titleEl = document.createElement("span");
|
||
titleEl.className = "smart-timeline-title";
|
||
const href = deepLinkHref(ev);
|
||
if (href) {
|
||
const a = document.createElement("a");
|
||
a.className = "smart-timeline-link";
|
||
a.href = href;
|
||
a.textContent = ev.title;
|
||
titleEl.appendChild(a);
|
||
} else {
|
||
titleEl.textContent = ev.title;
|
||
}
|
||
head.appendChild(titleEl);
|
||
|
||
const kindChip = document.createElement("span");
|
||
kindChip.className = `smart-timeline-kind-chip smart-timeline-kind-chip--${ev.kind}`;
|
||
kindChip.textContent = t(kindKey(ev.kind));
|
||
head.appendChild(kindChip);
|
||
|
||
if (ev.rule_code) {
|
||
const ruleChip = document.createElement("span");
|
||
ruleChip.className = "smart-timeline-rule-chip";
|
||
ruleChip.textContent = ev.rule_code;
|
||
head.appendChild(ruleChip);
|
||
}
|
||
|
||
// "voraussichtlich" / "vom Gericht" / "überfällig" status pill on
|
||
// projected rows so the user reads the row's nature at a glance.
|
||
if (ev.kind === "projected") {
|
||
const statusPill = document.createElement("span");
|
||
statusPill.className = `smart-timeline-status-pill smart-timeline-status-pill--${ev.status}`;
|
||
statusPill.textContent = t(statusKey(ev.status));
|
||
head.appendChild(statusPill);
|
||
}
|
||
|
||
body.appendChild(head);
|
||
|
||
if (ev.description) {
|
||
const desc = document.createElement("div");
|
||
desc.className = "smart-timeline-desc";
|
||
desc.textContent = ev.description;
|
||
body.appendChild(desc);
|
||
}
|
||
|
||
// Depends-on footer (#31 layer 2) — surface the parent rule + its
|
||
// date right under the title so the user reads the dependency at a
|
||
// glance. "[Pfad anzeigen]" expands the full chain on demand.
|
||
if (ev.depends_on_rule_code) {
|
||
body.appendChild(renderDependsOn(ev));
|
||
}
|
||
|
||
// Click-to-anchor affordance (Slice 2 §6.2) — projected rows expose
|
||
// "[Datum setzen]" inline editor; actuals from rules expose a
|
||
// "[Datum ändern]" variant that PATCHes via the same endpoint.
|
||
if (ev.kind === "projected" && ev.deadline_rule_id && opts.projectId) {
|
||
body.appendChild(renderAnchorAction(ev, opts));
|
||
}
|
||
|
||
li.appendChild(body);
|
||
|
||
// Row-level navigation — same pattern as .entity-event (t-paliad-103).
|
||
if (href) {
|
||
li.classList.add("smart-timeline-row--clickable");
|
||
li.addEventListener("click", (e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest("a") || target.closest("button") || target.closest("input")) return;
|
||
window.location.href = href;
|
||
});
|
||
}
|
||
|
||
return li;
|
||
}
|
||
|
||
function renderDependsOn(ev: TimelineEvent): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "smart-timeline-depends-on";
|
||
const code = ev.depends_on_rule_code ?? "";
|
||
const name = ev.depends_on_rule_name || code;
|
||
const dateText = ev.depends_on_date
|
||
? formatDateOnly(dateOnlyISO(ev.depends_on_date) ?? "")
|
||
: t("projects.detail.smarttimeline.depends_on.date_open");
|
||
const prefix = t("projects.detail.smarttimeline.depends_on.prefix");
|
||
const txt = document.createElement("span");
|
||
txt.textContent = `${prefix}: ${name} (${code}, ${dateText})`;
|
||
wrap.appendChild(txt);
|
||
|
||
const expand = document.createElement("button");
|
||
expand.type = "button";
|
||
expand.className = "smart-timeline-depends-on-expand";
|
||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||
expand.addEventListener("click", () => {
|
||
if (wrap.classList.contains("smart-timeline-depends-on--expanded")) {
|
||
wrap.classList.remove("smart-timeline-depends-on--expanded");
|
||
const list = wrap.querySelector(".smart-timeline-depends-on-path");
|
||
if (list) list.remove();
|
||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||
return;
|
||
}
|
||
wrap.classList.add("smart-timeline-depends-on--expanded");
|
||
const list = document.createElement("div");
|
||
list.className = "smart-timeline-depends-on-path";
|
||
// The walked chain isn't pre-computed server-side beyond the
|
||
// immediate parent; the backend annotation gives one hop. Future
|
||
// slice can deepen this — for v1 we surface the immediate parent
|
||
// (already in the prefix line) and a hint that the user can click
|
||
// the parent's row to see its own dependency.
|
||
const hint = document.createElement("span");
|
||
hint.className = "smart-timeline-depends-on-hint";
|
||
hint.textContent = t("projects.detail.smarttimeline.depends_on.path_hint");
|
||
list.appendChild(hint);
|
||
wrap.appendChild(list);
|
||
expand.textContent = t("projects.detail.smarttimeline.depends_on.hide_path");
|
||
});
|
||
wrap.appendChild(expand);
|
||
return wrap;
|
||
}
|
||
|
||
function renderAnchorAction(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "smart-timeline-anchor";
|
||
|
||
const trigger = document.createElement("button");
|
||
trigger.type = "button";
|
||
trigger.className = "smart-timeline-anchor-btn";
|
||
trigger.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||
wrap.appendChild(trigger);
|
||
|
||
trigger.addEventListener("click", () => {
|
||
if (wrap.classList.contains("smart-timeline-anchor--editing")) return;
|
||
wrap.classList.add("smart-timeline-anchor--editing");
|
||
trigger.style.display = "none";
|
||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||
});
|
||
|
||
return wrap;
|
||
}
|
||
|
||
function buildAnchorEditor(
|
||
ev: TimelineEvent,
|
||
opts: RenderOptions,
|
||
wrap: HTMLElement,
|
||
): HTMLElement {
|
||
const editor = document.createElement("form");
|
||
editor.className = "smart-timeline-anchor-form";
|
||
editor.setAttribute("aria-label", t("projects.detail.smarttimeline.anchor.set"));
|
||
editor.addEventListener("submit", (e) => e.preventDefault());
|
||
|
||
const dateInput = document.createElement("input");
|
||
dateInput.type = "date";
|
||
dateInput.className = "smart-timeline-anchor-date";
|
||
dateInput.required = true;
|
||
if (ev.date) dateInput.value = dateOnlyISO(ev.date) ?? "";
|
||
editor.appendChild(dateInput);
|
||
|
||
const submit = document.createElement("button");
|
||
submit.type = "submit";
|
||
submit.className = "smart-timeline-anchor-submit";
|
||
submit.textContent = t("projects.detail.smarttimeline.anchor.save");
|
||
editor.appendChild(submit);
|
||
|
||
const cancel = document.createElement("button");
|
||
cancel.type = "button";
|
||
cancel.className = "smart-timeline-anchor-cancel";
|
||
cancel.textContent = t("projects.detail.smarttimeline.anchor.cancel");
|
||
cancel.addEventListener("click", () => {
|
||
wrap.innerHTML = "";
|
||
const trig = document.createElement("button");
|
||
trig.type = "button";
|
||
trig.className = "smart-timeline-anchor-btn";
|
||
trig.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||
wrap.classList.remove("smart-timeline-anchor--editing");
|
||
wrap.appendChild(trig);
|
||
trig.addEventListener("click", () => {
|
||
wrap.innerHTML = "";
|
||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||
wrap.classList.add("smart-timeline-anchor--editing");
|
||
});
|
||
});
|
||
editor.appendChild(cancel);
|
||
|
||
const msg = document.createElement("div");
|
||
msg.className = "smart-timeline-anchor-msg";
|
||
editor.appendChild(msg);
|
||
|
||
editor.addEventListener("submit", async () => {
|
||
if (!opts.projectId) return;
|
||
if (!ev.rule_code) return;
|
||
const date = dateInput.value;
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
msg.textContent = t("projects.detail.smarttimeline.anchor.invalid_date");
|
||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||
return;
|
||
}
|
||
submit.disabled = true;
|
||
cancel.disabled = true;
|
||
msg.classList.remove("smart-timeline-anchor-msg--error");
|
||
msg.textContent = t("projects.detail.smarttimeline.anchor.saving");
|
||
try {
|
||
const resp = await fetch(
|
||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline/anchor`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
rule_code: ev.rule_code,
|
||
actual_date: date,
|
||
}),
|
||
},
|
||
);
|
||
if (resp.ok) {
|
||
msg.textContent = t("projects.detail.smarttimeline.anchor.saved");
|
||
if (opts.onChange) await opts.onChange();
|
||
return;
|
||
}
|
||
if (resp.status === 409) {
|
||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||
return;
|
||
}
|
||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||
} catch {
|
||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||
} finally {
|
||
submit.disabled = false;
|
||
cancel.disabled = false;
|
||
}
|
||
});
|
||
|
||
return editor;
|
||
}
|
||
|
||
function renderPredecessorError(
|
||
msg: HTMLElement,
|
||
payload: PredecessorMissingPayload,
|
||
_ev: TimelineEvent,
|
||
opts: RenderOptions,
|
||
_dateInput: HTMLInputElement,
|
||
_submit: HTMLButtonElement,
|
||
_cancel: HTMLButtonElement,
|
||
): void {
|
||
msg.innerHTML = "";
|
||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||
msg.classList.add("smart-timeline-anchor-msg--predecessor");
|
||
|
||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||
const message = lang === "en" ? payload.message_en : payload.message_de;
|
||
const main = document.createElement("p");
|
||
main.textContent = message;
|
||
msg.appendChild(main);
|
||
|
||
// "Stattdessen <predecessor> erfassen" — pre-fills the editor for
|
||
// the missing parent rule, scrolls to its row if present, falls back
|
||
// to a fresh editor in-place.
|
||
const link = document.createElement("button");
|
||
link.type = "button";
|
||
link.className = "smart-timeline-anchor-predecessor-link";
|
||
const predName =
|
||
lang === "en" ? payload.missing_rule_name_en : payload.missing_rule_name_de;
|
||
link.textContent =
|
||
lang === "en"
|
||
? `Anchor „${predName}“ instead`
|
||
: `Stattdessen „${predName}“ erfassen`;
|
||
link.addEventListener("click", () => {
|
||
// Find the projected row for missing_rule_code and scroll into view;
|
||
// the row's own [Datum setzen] button takes it from there.
|
||
const targetRow = findRowForRuleCode(payload.missing_rule_code);
|
||
if (targetRow) {
|
||
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
const btn = targetRow.querySelector<HTMLButtonElement>(
|
||
".smart-timeline-anchor-btn",
|
||
);
|
||
if (btn) btn.click();
|
||
}
|
||
});
|
||
msg.appendChild(link);
|
||
}
|
||
|
||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||
for (const r of Array.from(rows)) {
|
||
const chip = r.querySelector(".smart-timeline-rule-chip");
|
||
if (chip && chip.textContent === ruleCode) return r;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function deepLinkHref(ev: TimelineEvent): string | null {
|
||
if (ev.kind === "deadline" && ev.deadline_id) {
|
||
return `/deadlines/${ev.deadline_id}`;
|
||
}
|
||
if (ev.kind === "appointment" && ev.appointment_id) {
|
||
return `/appointments/${ev.appointment_id}`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function statusGlyph(status: TimelineEvent["status"]): string {
|
||
switch (status) {
|
||
case "done": return "✓";
|
||
case "open": return "…";
|
||
case "overdue": return "!";
|
||
case "court_set": return "▢";
|
||
case "predicted": return "░";
|
||
case "predicted_overdue": return "░!";
|
||
case "off_script": return "⊕";
|
||
default: return "·";
|
||
}
|
||
}
|
||
|
||
function statusKey(status: TimelineEvent["status"]) {
|
||
return `projects.detail.smarttimeline.status.${status}` as const;
|
||
}
|
||
|
||
function kindKey(kind: TimelineEvent["kind"]) {
|
||
return `projects.detail.smarttimeline.kind.${kind}` as const;
|
||
}
|
||
|
||
function dateOnlyISO(raw: string | null | undefined): string | null {
|
||
if (!raw) return null;
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||
const d = new Date(raw);
|
||
if (isNaN(d.getTime())) return null;
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function todayLocalISO(): string {
|
||
const d = new Date();
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function byDateAsc(a: TimelineEvent, b: TimelineEvent): number {
|
||
const ai = dateOnlyISO(a.date) ?? "";
|
||
const bi = dateOnlyISO(b.date) ?? "";
|
||
if (ai === bi) return a.title.localeCompare(b.title);
|
||
return ai < bi ? -1 : 1;
|
||
}
|
||
|
||
function formatDateOnly(iso: string): string {
|
||
if (!iso) return "—";
|
||
const parts = iso.split("-");
|
||
if (parts.length !== 3) return iso;
|
||
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||
if (isNaN(d.getTime())) return iso;
|
||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
}
|