feat(t-paliad-171): mount SmartTimeline + "+ Eintrag" modal in /projects/<id> Verlauf

Replaces the legacy <ul.entity-events> Verlauf rendering with the new
SmartTimeline. Slice 1 wiring:

  - loadTimeline(id) calls /api/projects/{id}/timeline (the new
    endpoint backed by ProjectionService) and renderSmartTimeline
    paints into <div#project-smart-timeline>.
  - "Audit-Log anzeigen" header toggle re-fetches with
    ?include=audit_full, broadening the project_events filter to
    every audit row (legacy Verlauf chronological view). State
    persists per-project in localStorage so flipping it on for one
    case doesn't carry across to others.
  - "+ Eintrag" CTA opens a modal. "Eigener Meilenstein" submits
    via POST /api/projects/{id}/timeline/milestone and re-renders;
    Frist + Termin route to the existing /deadlines/new and
    /appointments/new flows; CCR + R.30 are disabled-with-tooltip
    "kommt mit Slice 3" per the design.
  - Subtree toggle now also drives the timeline (passes
    ?direct_only=true when the user flips off "Inkl. Unterprojekte").
  - Project-appointment add path also re-fetches the timeline so the
    new appointment surfaces immediately.

The legacy renderEvents() rendering path stays as-is (dead, but the
function is still called in places). It will be removed once
/timeline?include=audit_full has had a deploy of soak time and the
audit-toggle is the only path that feeds the legacy markup. Slice 2
revisits.

The FilterBar from t-paliad-170 (riemann's port) keeps mounting and
driving its customRunner — facets still narrow the legacy `events`
array. The bar gaining timeline_* axes lands later in the slice
sequence (design §8); Slice 1 ships the timeline beneath the existing
bar untouched.

Design ref: docs/design-smart-timeline-2026-05-08.md §10 Slice 1.
This commit is contained in:
m
2026-05-08 23:41:11 +02:00
parent 4a5d56d9e6
commit 7057fe5d25
2 changed files with 286 additions and 14 deletions

View File

@@ -11,6 +11,7 @@ import {
} from "./project-form";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent } from "./views/shape-timeline";
interface Project {
id: string;
@@ -224,6 +225,13 @@ const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
// SmartTimeline (t-paliad-171) — separate row set + audit-toggle state.
// The legacy `events` array drives the old chronological rendering only
// when the user flips on "Audit-Log anzeigen"; default mode renders
// `timelineRows` from /api/projects/{id}/timeline (the new endpoint).
let timelineRows: SmartTimelineEvent[] = [];
let timelineAuditFull = parseAuditFullPersisted();
// t-paliad-170 — Verlauf FilterBar state.
//
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
@@ -387,6 +395,66 @@ async function loadEvents(id: string) {
}
}
// SmartTimeline (t-paliad-171) — fetches the merged timeline from the
// new /api/projects/{id}/timeline endpoint. Slice 1 returns actuals
// (deadlines + appointments + opted-in project_events); future slices
// add projected rows additively. The audit-full toggle broadens the
// project_events filter to include rows without timeline_kind set.
async function loadTimeline(id: string): Promise<void> {
const params = new URLSearchParams();
if (timelineAuditFull) params.set("include", "audit_full");
if (!subtreeMode) params.set("direct_only", "true");
const qs = params.toString();
const url = `/api/projects/${encodeURIComponent(id)}/timeline${qs ? "?" + qs : ""}`;
try {
const resp = await fetch(url);
if (resp.ok) {
timelineRows = (await resp.json()) ?? [];
} else {
timelineRows = [];
}
} catch {
timelineRows = [];
}
}
function renderTimeline() {
const host = document.getElementById("project-smart-timeline");
if (!host) return;
renderSmartTimeline(host, timelineRows);
}
// Audit-full toggle persistence: per-project flag in localStorage so a
// user who flips the legacy view on for one project doesn't see the
// audit clutter on every other project they open.
function auditFullStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.audit_full.${id}`;
}
function parseAuditFullPersisted(): boolean {
// Project ID isn't known yet at module init; fall back to false here
// and re-read in initSmartTimelineAuditToggle once project is loaded.
return false;
}
function readPersistedAuditFull(): boolean {
try {
return localStorage.getItem(auditFullStorageKey()) === "1";
} catch {
return false;
}
}
function writePersistedAuditFull(on: boolean) {
try {
if (on) localStorage.setItem(auditFullStorageKey(), "1");
else localStorage.removeItem(auditFullStorageKey());
} catch {
// ignore
}
}
async function loadMoreEvents(id: string) {
if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return;
const cursor = rawEventsLastID;
@@ -600,6 +668,8 @@ function initProjectAppointmentForm() {
renderAppointments();
await loadEvents(project.id);
renderEvents();
await loadTimeline(project.id);
renderTimeline();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("projects.error.generic");
@@ -904,6 +974,141 @@ function initEventsLoadMore() {
});
}
// initSmartTimelineAuditToggle — wires the "Audit-Log anzeigen" button
// in the Verlauf tab header. When ON, the next /timeline fetch passes
// ?include=audit_full so every paliad.project_events row surfaces (the
// legacy chronological Verlauf view); OFF only shows rows that opted
// into timeline_kind. State persists in localStorage per project.
function initSmartTimelineAuditToggle(id: string) {
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
if (!btn) return;
// Re-read from localStorage now that project is loaded.
timelineAuditFull = readPersistedAuditFull();
refreshAuditToggleLabel();
btn.addEventListener("click", async () => {
timelineAuditFull = !timelineAuditFull;
writePersistedAuditFull(timelineAuditFull);
refreshAuditToggleLabel();
await loadTimeline(id);
renderTimeline();
});
}
function refreshAuditToggleLabel() {
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", timelineAuditFull ? "true" : "false");
btn.textContent = timelineAuditFull
? t("projects.detail.smarttimeline.audit.toggle.hide")
: t("projects.detail.smarttimeline.audit.toggle.show");
btn.classList.toggle("subtree-toggle--active", timelineAuditFull);
}
// initSmartTimelineAddModal — wires the "+ Eintrag" CTA + modal. Only
// the "Eigener Meilenstein" route is fully wired in Slice 1 (writes
// to /api/projects/{id}/timeline/milestone); Frist + Termin are link
// buttons to the existing flows; CCR + R.30 are disabled with a
// "Slice 3" tooltip per the brief.
function initSmartTimelineAddModal(id: string) {
const cta = document.getElementById("smart-timeline-add-btn") as HTMLButtonElement | null;
const modal = document.getElementById("smart-timeline-add-modal") as HTMLDivElement | null;
if (!cta || !modal) return;
const choices = document.querySelector<HTMLDivElement>(".smart-timeline-add-choices");
const form = document.getElementById("smart-timeline-milestone-form") as HTMLFormElement | null;
const milestoneBtn = document.getElementById("smart-timeline-add-milestone") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("smart-timeline-milestone-cancel") as HTMLButtonElement | null;
const closeBtn = document.getElementById("smart-timeline-modal-close") as HTMLButtonElement | null;
const titleInput = document.getElementById("smart-timeline-milestone-title") as HTMLInputElement | null;
const dateInput = document.getElementById("smart-timeline-milestone-date") as HTMLInputElement | null;
const descInput = document.getElementById("smart-timeline-milestone-desc") as HTMLTextAreaElement | null;
const msg = document.getElementById("smart-timeline-milestone-msg") as HTMLDivElement | null;
const dlLink = document.getElementById("smart-timeline-add-deadline") as HTMLAnchorElement | null;
const apptLink = document.getElementById("smart-timeline-add-appointment") as HTMLAnchorElement | null;
if (dlLink) dlLink.href = `/deadlines/new?project=${encodeURIComponent(id)}`;
if (apptLink) apptLink.href = `/appointments/new?project=${encodeURIComponent(id)}`;
const open = () => {
modal.style.display = "";
if (choices) choices.style.display = "";
if (form) form.style.display = "none";
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
};
const close = () => {
modal.style.display = "none";
if (form) form.reset();
};
cta.addEventListener("click", open);
if (closeBtn) closeBtn.addEventListener("click", close);
if (cancelBtn) cancelBtn.addEventListener("click", close);
// Click outside the card → close.
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
if (milestoneBtn && form) {
milestoneBtn.addEventListener("click", () => {
if (choices) choices.style.display = "none";
form.style.display = "";
titleInput?.focus();
});
}
if (form && titleInput) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = titleInput.value.trim();
if (!title) {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.title_required");
msg.className = "form-msg form-msg-error";
}
return;
}
const payload: Record<string, unknown> = { title };
const desc = descInput?.value.trim();
if (desc) payload.description = desc;
const date = dateInput?.value;
if (date) payload.occurred_at = date;
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}/timeline/milestone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
close();
await loadTimeline(id);
renderTimeline();
} else {
const data = (await resp.json().catch(() => ({}))) as { error?: string };
if (msg) {
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
}
} catch {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} finally {
submitBtn.disabled = false;
}
});
}
}
function renderParties() {
const tbody = document.getElementById("parties-body")!;
const empty = document.getElementById("parties-empty")!;
@@ -1371,6 +1576,7 @@ async function main() {
await Promise.all([
loadParties(id),
loadEvents(id),
loadTimeline(id),
loadDeadlines(id),
loadAppointments(id),
loadAncestors(id),
@@ -1389,6 +1595,7 @@ async function main() {
renderBreadcrumb();
renderParties();
renderEvents();
renderTimeline();
renderDeadlines();
renderAppointments();
renderChildren();
@@ -1403,6 +1610,8 @@ async function main() {
initDelete();
initEventsLoadMore();
initSubtreeToggles(id);
initSmartTimelineAuditToggle(id);
initSmartTimelineAddModal(id);
initAttachUnitForm(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
@@ -1560,10 +1769,18 @@ function initSubtreeToggles(id: string) {
// customRunner (so the current filter state stays applied).
// Falls back to a direct loadEvents call when the bar hasn't
// mounted yet (e.g. on a project with rendering errors).
// Also refresh the SmartTimeline since direct_only is mirrored
// onto its read path (t-paliad-171).
const eventsRefresh = verlaufBar
? verlaufBar.refresh()
: loadEvents(id).then(() => renderEvents());
await Promise.all([eventsRefresh, loadDeadlines(id), loadAppointments(id)]);
await Promise.all([
eventsRefresh,
loadTimeline(id),
loadDeadlines(id),
loadAppointments(id),
]);
renderTimeline();
renderDeadlines();
renderAppointments();
});

View File

@@ -82,24 +82,79 @@ export function renderProjectsDetail(): string {
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
</nav>
{/* History (Verlauf) */}
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
The legacy <ul.entity-events> + Mehr-laden controls are
replaced by the vertical timeline (rendered by
client/views/shape-timeline.ts). The bar from t-paliad-170
keeps driving filter state via its customRunner. */}
<section className="entity-tab-panel" id="tab-history">
<div className="party-controls">
<div className="smart-timeline-controls">
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
{/* t-paliad-170 — FilterBar Phase 2 slice. Mounted by
projects-detail.ts when the Verlauf tab is active. */}
<div id="project-events-filter-bar" />
<ul className="entity-events" id="project-events-list" />
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
</p>
<div className="entity-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projects.detail.verlauf.loadMore">
Mehr laden
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
Audit-Log anzeigen
</button>
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
+ Eintrag
</button>
</div>
<div id="project-events-filter-bar" />
<div id="project-smart-timeline" className="smart-timeline" />
{/* "Eigener Meilenstein" modal. Hidden by default; opened
by the "+ Eintrag" CTA above. The other modal options
route to existing flows (see client wiring). */}
<div id="smart-timeline-add-modal" className="smart-timeline-modal" style="display:none" role="dialog" aria-modal="true">
<div className="smart-timeline-modal-card">
<h3 data-i18n="projects.detail.smarttimeline.add.modal.title">
Neuer Eintrag im SmartTimeline
</h3>
<div className="smart-timeline-add-choices">
<a id="smart-timeline-add-deadline" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.deadline">
Frist anlegen
</a>
<a id="smart-timeline-add-appointment" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.appointment">
Termin anlegen
</a>
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.counterclaim">
Widerklage (CCR)
</button>
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.amend">
Antrag auf Änderung (R.30)
</button>
<button type="button" id="smart-timeline-add-milestone" className="smart-timeline-add-choice smart-timeline-add-choice--primary" data-i18n="projects.detail.smarttimeline.add.choice.milestone">
Eigener Meilenstein
</button>
</div>
<form id="smart-timeline-milestone-form" className="entity-form" style="display:none" autocomplete="off">
<div className="form-field">
<label htmlFor="smart-timeline-milestone-title" data-i18n="projects.detail.smarttimeline.milestone.title">Titel</label>
<input type="text" id="smart-timeline-milestone-title" required maxLength={200} />
</div>
<div className="form-field">
<label htmlFor="smart-timeline-milestone-date" data-i18n="projects.detail.smarttimeline.milestone.date">Datum (optional)</label>
<input type="date" id="smart-timeline-milestone-date" />
</div>
<div className="form-field">
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
<textarea id="smart-timeline-milestone-desc" rows={3} />
</div>
<div id="smart-timeline-milestone-msg" className="form-msg" />
<div className="form-field-row">
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.add.submit">Speichern</button>
</div>
</form>
<div className="smart-timeline-modal-close-row">
<button type="button" className="btn-secondary btn-small" id="smart-timeline-modal-close" data-i18n="projects.detail.smarttimeline.add.cancel">
Abbrechen
</button>
</div>
</div>
</div>
</section>