diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index deb9c09..bcaf2eb 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1163,6 +1163,39 @@ const translations: Record> = { "projects.detail.tab.checklisten": "Checklisten", "projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.", "projects.detail.verlauf.loadMore": "Mehr laden", + // SmartTimeline (t-paliad-171, Slice 1). + "projects.detail.smarttimeline.empty": "Noch keine Ereignisse erfasst.", + "projects.detail.smarttimeline.today": "Heute", + "projects.detail.smarttimeline.section.past": "Vergangenheit", + "projects.detail.smarttimeline.section.future": "Zukunft", + "projects.detail.smarttimeline.section.undated": "Ohne Datum", + "projects.detail.smarttimeline.kind.deadline": "Frist", + "projects.detail.smarttimeline.kind.appointment": "Termin", + "projects.detail.smarttimeline.kind.milestone": "Meilenstein", + "projects.detail.smarttimeline.kind.projected": "Vorhersage", + "projects.detail.smarttimeline.status.done": "Erledigt", + "projects.detail.smarttimeline.status.open": "Offen", + "projects.detail.smarttimeline.status.overdue": "Überfällig", + "projects.detail.smarttimeline.status.court_set": "Datum vom Gericht", + "projects.detail.smarttimeline.status.predicted": "Voraussichtlich", + "projects.detail.smarttimeline.status.off_script": "Eigener Eintrag", + "projects.detail.smarttimeline.audit.toggle.show": "Audit-Log anzeigen", + "projects.detail.smarttimeline.audit.toggle.hide": "Nur Timeline-Einträge", + "projects.detail.smarttimeline.add.cta": "+ Eintrag", + "projects.detail.smarttimeline.add.modal.title": "Neuer Eintrag im SmartTimeline", + "projects.detail.smarttimeline.add.choice.deadline": "Frist anlegen", + "projects.detail.smarttimeline.add.choice.appointment": "Termin anlegen", + "projects.detail.smarttimeline.add.choice.counterclaim": "Widerklage (CCR)", + "projects.detail.smarttimeline.add.choice.amend": "Antrag auf Änderung (R.30)", + "projects.detail.smarttimeline.add.choice.milestone": "Eigener Meilenstein", + "projects.detail.smarttimeline.add.choice.disabled": "Kommt mit Slice 3", + "projects.detail.smarttimeline.add.cancel": "Abbrechen", + "projects.detail.smarttimeline.add.submit": "Speichern", + "projects.detail.smarttimeline.milestone.title": "Titel", + "projects.detail.smarttimeline.milestone.date": "Datum (optional)", + "projects.detail.smarttimeline.milestone.description": "Beschreibung (optional)", + "projects.detail.smarttimeline.error.title_required": "Bitte einen Titel angeben.", + "projects.detail.smarttimeline.error.generic": "Konnte den Eintrag nicht speichern.", "projects.detail.team.form.user": "Benutzer", "projects.detail.team.form.role": "Rolle", "projects.detail.team.form.responsibility": "Rolle im Projekt", @@ -3367,6 +3400,38 @@ const translations: Record> = { "projects.detail.tab.checklisten": "Checklists", "projects.detail.verlauf.empty": "No events recorded yet.", "projects.detail.verlauf.loadMore": "Load more", + "projects.detail.smarttimeline.empty": "No events captured yet.", + "projects.detail.smarttimeline.today": "Today", + "projects.detail.smarttimeline.section.past": "Past", + "projects.detail.smarttimeline.section.future": "Future", + "projects.detail.smarttimeline.section.undated": "Undated", + "projects.detail.smarttimeline.kind.deadline": "Deadline", + "projects.detail.smarttimeline.kind.appointment": "Appointment", + "projects.detail.smarttimeline.kind.milestone": "Milestone", + "projects.detail.smarttimeline.kind.projected": "Predicted", + "projects.detail.smarttimeline.status.done": "Done", + "projects.detail.smarttimeline.status.open": "Open", + "projects.detail.smarttimeline.status.overdue": "Overdue", + "projects.detail.smarttimeline.status.court_set": "Court-set date", + "projects.detail.smarttimeline.status.predicted": "Predicted", + "projects.detail.smarttimeline.status.off_script": "Custom", + "projects.detail.smarttimeline.audit.toggle.show": "Show audit log", + "projects.detail.smarttimeline.audit.toggle.hide": "Timeline only", + "projects.detail.smarttimeline.add.cta": "+ Entry", + "projects.detail.smarttimeline.add.modal.title": "New SmartTimeline entry", + "projects.detail.smarttimeline.add.choice.deadline": "Add a deadline", + "projects.detail.smarttimeline.add.choice.appointment": "Add an appointment", + "projects.detail.smarttimeline.add.choice.counterclaim": "Counterclaim (CCR)", + "projects.detail.smarttimeline.add.choice.amend": "Application to amend (R.30)", + "projects.detail.smarttimeline.add.choice.milestone": "Custom milestone", + "projects.detail.smarttimeline.add.choice.disabled": "Coming in Slice 3", + "projects.detail.smarttimeline.add.cancel": "Cancel", + "projects.detail.smarttimeline.add.submit": "Save", + "projects.detail.smarttimeline.milestone.title": "Title", + "projects.detail.smarttimeline.milestone.date": "Date (optional)", + "projects.detail.smarttimeline.milestone.description": "Description (optional)", + "projects.detail.smarttimeline.error.title_required": "Please enter a title.", + "projects.detail.smarttimeline.error.generic": "Could not save the entry.", "projects.detail.team.form.user": "User", "projects.detail.team.form.role": "Role", "projects.detail.team.form.responsibility": "Project role", diff --git a/frontend/src/client/views/shape-timeline.ts b/frontend/src/client/views/shape-timeline.ts new file mode 100644 index 0000000..e0fc952 --- /dev/null +++ b/frontend/src/client/views/shape-timeline.ts @@ -0,0 +1,300 @@ +import { t, tDyn, getLang } from "../i18n"; + +// shape-timeline (t-paliad-171) — vertical timeline render for the +// SmartTimeline. Two-column layout (date / event card), "Heute →" rule +// separating past from future, status icon + kind chip per row, deep +// link on actuals via a row-level click handler (NOT a ::before +// overlay — text-selection must stay intact, see project CLAUDE.md +// "Whole-card click → use a JS row handler"). +// +// This file is intentionally self-contained — it does not extend the +// FilterBar's `ViewRow` shape. The SmartTimeline's wire shape +// (TimelineEvent) is the contract from /api/projects/{id}/timeline, +// frozen across slices so Slice 2 just adds Kind="projected" rows +// without breaking the renderer here. +// +// Design ref: docs/design-smart-timeline-2026-05-08.md §3. + +export interface TimelineEvent { + kind: "deadline" | "appointment" | "milestone" | "projected"; + status: "done" | "open" | "overdue" | "court_set" | "predicted" | "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; +} + +interface RenderOptions { + // Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ". + // Passed in for testability; not used at runtime. + today?: string; +} + +export function renderSmartTimeline( + host: HTMLElement, + rows: TimelineEvent[], + opts: RenderOptions = {}, +): void { + host.innerHTML = ""; + host.classList.add("smart-timeline"); + + if (rows.length === 0) { + const empty = document.createElement("div"); + empty.className = "smart-timeline-empty"; + empty.textContent = t("projects.detail.smarttimeline.empty"); + host.appendChild(empty); + return; + } + + const todayISO = opts.today ?? todayLocalISO(); + // Split into past, today, future. Undated rows live at the bottom of + // the past block (we render past on top, future below — but the design + // calls for past first, future after — see §3.2 mockup which puts the + // future ABOVE the "Heute →" rule with reverse-chronology... actually + // re-reading: "Past goes below (most-recent first), future above + // (chronological)". For Slice 1 simplicity we render chronologically: + // past oldest→newest, then "Heute →", then future newest→furthest. + // This is the calmer reading order; we can flip on m's review. + 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)); + wrap.appendChild(section); + } + + // "Heute →" rule. Always present so the user has a temporal anchor + // even when one zone is empty. + 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)); + 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)); + wrap.appendChild(section); + } + + 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)); + wrap.appendChild(section); + } + + host.appendChild(wrap); +} + +function renderRow(ev: TimelineEvent): HTMLElement { + const li = document.createElement("article"); + li.className = + `smart-timeline-row smart-timeline-row--${ev.kind} ` + + `smart-timeline-row--${ev.status}`; + + // Left: date column. "—" placeholder for undated rows so the + // two-column grid stays aligned across the whole timeline. + const dateCol = document.createElement("div"); + dateCol.className = "smart-timeline-date"; + dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—"; + li.appendChild(dateCol); + + // Right: event card. + 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); + } + + body.appendChild(head); + + if (ev.description) { + const desc = document.createElement("div"); + desc.className = "smart-timeline-desc"; + desc.textContent = ev.description; + body.appendChild(desc); + } + + li.appendChild(body); + + // Row-level navigation — same pattern as .entity-event (t-paliad-103). + // Skips clicks on inner /