Files
paliad/frontend/src/client/views/shape-timeline.ts
mAi 3ff1b23238 fix(timeline): t-paliad-237 — anchor lookup must traverse linked proceedings
On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.

The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.

Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).

Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
2026-05-22 23:43:15 +02:00

1016 lines
37 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;
}
// t-paliad-237 — server tells us the anchored rule belongs to the
// parent infringement project, not this CCR. Frontend renders the
// message with a clickable link to the parent project.
export interface CrossProceedingAnchorPayload {
error: "cross_proceeding_anchor";
requested_rule_code: string;
requested_rule_name_de: string;
requested_rule_name_en: string;
parent_project_id: string;
parent_project_title: string;
parent_project_url: 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
| CrossProceedingAnchorPayload;
if (payload.error === "cross_proceeding_anchor") {
renderCrossProceedingError(msg, payload, opts);
return;
}
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);
}
// t-paliad-237 — rule belongs to the parent inf project, not this CCR.
// Render the bilingual message + a link to the parent project so the
// user can navigate over and anchor the rule there. We deliberately do
// NOT auto-route the write across projects (out of scope per brief).
function renderCrossProceedingError(
msg: HTMLElement,
payload: CrossProceedingAnchorPayload,
opts: RenderOptions,
): void {
msg.innerHTML = "";
msg.classList.add("smart-timeline-anchor-msg--error");
msg.classList.add("smart-timeline-anchor-msg--cross-proceeding");
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
const main = document.createElement("p");
main.textContent = lang === "en" ? payload.message_en : payload.message_de;
msg.appendChild(main);
const link = document.createElement("a");
link.className = "smart-timeline-anchor-parent-link";
link.href = payload.parent_project_url;
link.textContent =
lang === "en"
? `Open „${payload.parent_project_title}`
: `${payload.parent_project_title}“ öffnen`;
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",
});
}