Files
paliad/frontend/src/client/views/shape-timeline.ts
m c2f1c29b10 fix(t-paliad-176): FilterBar timeline narrowing + Nur-direkt subtree skip
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).
2026-05-09 18:52:01 +02:00

967 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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",
});
}