diff --git a/frontend/src/client/filter-bar/axes.ts b/frontend/src/client/filter-bar/axes.ts index 76d2012..ad8163f 100644 --- a/frontend/src/client/filter-bar/axes.ts +++ b/frontend/src/client/filter-bar/axes.ts @@ -21,12 +21,19 @@ export interface AxisCtx { patch(delta: Partial): void; } +// RenderAxisOpts — per-surface tuning the bar threads through to axis +// renderers. Currently only time-axis chip presets; future axes can grow +// here without changing every call site. +export interface RenderAxisOpts { + timePresets?: NonNullable["horizon"][]; +} + // renderAxis returns the HTML element for a single axis. The bar's // mountFilterBar appends the result to its internal toolbar. Returns // null when the axis is ignored (e.g. surface didn't declare it). -export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null { +export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): HTMLElement | null { switch (axis) { - case "time": return renderTimeAxis(ctx); + case "time": return renderTimeAxis(ctx, opts?.timePresets); case "project": return null; // populated lazily — see attachProjectAxis below case "personal_only": return renderPersonalOnlyAxis(ctx); case "approval_viewer_role": return renderApprovalRoleAxis(ctx); @@ -34,15 +41,15 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null { case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx); case "deadline_status": return renderDeadlineStatusAxis(ctx); case "appointment_type": return renderAppointmentTypeAxis(ctx); + case "project_event_kind": return renderProjectEventKindAxis(ctx); case "shape": return renderShapeAxis(ctx); case "density": return renderDensityAxis(ctx); case "sort": return renderSortAxis(ctx); // Per-source predicates that need their own widgets and a roundtrip // through fetched option lists. Phase 2+ will fill these in by - // wiring the existing event-types / project-list components. + // wiring the existing event-types component. case "deadline_event_type": - case "project_event_kind": return null; } } @@ -51,25 +58,44 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null { // time — chip cluster (presets + Anpassen) // ---------------------------------------------------------------------- -const TIME_PRESETS: Array<{ value: BarState["time"] extends infer T ? (T extends { horizon: infer H } ? H : never) : never; key: I18nKey }> = [ - { value: "next_7d", key: "views.bar.time.next_7d" }, - { value: "next_30d", key: "views.bar.time.next_30d" }, - { value: "next_90d", key: "views.bar.time.next_90d" }, - { value: "past_30d", key: "views.bar.time.past_30d" }, - { value: "any", key: "views.bar.time.any" }, +type TimeHorizonValue = NonNullable["horizon"]; + +const TIME_PRESET_LABELS: Record = { + next_7d: "views.bar.time.next_7d", + next_30d: "views.bar.time.next_30d", + next_90d: "views.bar.time.next_90d", + past_7d: "views.bar.time.past_7d", + past_30d: "views.bar.time.past_30d", + past_90d: "views.bar.time.past_90d", + any: "views.bar.time.any", + all: "views.bar.time.all", + custom: "views.bar.time.custom", +}; + +const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [ + "next_7d", "next_30d", "next_90d", "past_30d", "any", ]; -function renderTimeAxis(ctx: AxisCtx): HTMLElement { +function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement { const wrap = group("views.bar.label.time"); const row = chipRow(); + const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS; + // "any" / "all" are both unbounded — clearing state is the cleanest + // representation, so each maps to "no overlay" rather than a stored + // horizon. The chip's active state then keys off "no time set". const current = ctx.get("time")?.horizon ?? "any"; - for (const preset of TIME_PRESETS) { - const chip = chipBtn(t(preset.key), preset.value === current); + for (const preset of presets) { + if (preset === "custom") continue; // custom rendered separately below + const isUnbounded = preset === "any" || preset === "all"; + const isActive = isUnbounded + ? !ctx.get("time") + : preset === current; + const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive); chip.addEventListener("click", () => { - if (preset.value === "any") { + if (isUnbounded) { ctx.patch({ time: undefined }); } else { - ctx.patch({ time: { horizon: preset.value } }); + ctx.patch({ time: { horizon: preset } }); } }); row.appendChild(chip); @@ -249,6 +275,51 @@ function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement { return wrap; } +// ---------------------------------------------------------------------- +// project_event_kind — chip cluster (multi-select) +// +// Mirrors KnownProjectEventKinds in internal/services/filter_spec.go. +// Labels reuse the existing `event.title.` translation table so +// the chip text matches the Verlauf row title for the same event type. +// ---------------------------------------------------------------------- + +const PROJECT_EVENT_KINDS: string[] = [ + "project_created", + "project_archived", + "project_reparented", + "project_type_changed", + "status_changed", + "deadline_created", + "deadline_completed", + "deadline_reopened", + "appointment_created", + "appointment_updated", + "appointment_deleted", + "approval_decided", + "member_role_changed", +]; + +function renderProjectEventKindAxis(ctx: AxisCtx): HTMLElement { + const wrap = group("views.bar.label.project_event_kind"); + const row = chipRow(); + const all = chipBtn(t("views.bar.common.all"), !ctx.get("project_event_kind")?.length); + all.addEventListener("click", () => ctx.patch({ project_event_kind: undefined })); + row.appendChild(all); + const current = new Set(ctx.get("project_event_kind") ?? []); + for (const kind of PROJECT_EVENT_KINDS) { + const label = tDyn(`event.title.${kind}`); + const chip = chipBtn(label, current.has(kind)); + chip.addEventListener("click", () => { + if (current.has(kind)) current.delete(kind); + else current.add(kind); + ctx.patch({ project_event_kind: current.size ? [...current] : undefined }); + }); + row.appendChild(chip); + } + wrap.appendChild(row); + return wrap; +} + // ---------------------------------------------------------------------- // shape — segmented control (list / cards / calendar) // ---------------------------------------------------------------------- @@ -321,10 +392,6 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement { return wrap; } -// Suppress unused warning for tDyn — it's available for future axes -// (deadline_event_type) that need dynamic enum labels. -void tDyn; - // ---------------------------------------------------------------------- // shared helpers — group + chip + row // ---------------------------------------------------------------------- diff --git a/frontend/src/client/filter-bar/index.ts b/frontend/src/client/filter-bar/index.ts index 6d9d224..f7f3464 100644 --- a/frontend/src/client/filter-bar/index.ts +++ b/frontend/src/client/filter-bar/index.ts @@ -24,7 +24,7 @@ import { parseBar, encodeBar, } from "./url-codec"; -import { renderAxis, type AxisCtx } from "./axes"; +import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes"; import { openSaveModal } from "./save-modal"; import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types"; @@ -39,6 +39,11 @@ interface PrefsBlob { } export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle { + if (!!opts.customRunner === !!opts.systemViewSlug) { + throw new Error( + "mountFilterBar: exactly one of customRunner or systemViewSlug must be provided", + ); + } let state: BarState = {}; const ns = opts.urlNamespace; @@ -64,18 +69,25 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle { lastEffective = effective; const myVersion = ++runVersion; try { - const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filter: effective.filter }), - }); - if (myVersion !== runVersion) return; // a newer click superseded us - if (!r.ok) { - opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective); - return; + let result: ViewRunResult; + if (opts.customRunner) { + result = await opts.customRunner(effective); + } else { + const slug = opts.systemViewSlug as string; // ctor guard guarantees this + const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filter: effective.filter }), + }); + if (myVersion !== runVersion) return; // a newer click superseded us + if (!r.ok) { + opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective); + return; + } + result = (await r.json()) as ViewRunResult; } - const result = (await r.json()) as ViewRunResult; + if (myVersion !== runVersion) return; opts.onResult(result, effective); } catch (_e) { if (myVersion !== runVersion) return; @@ -104,11 +116,15 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle { }, }; + const axisRenderOpts: RenderAxisOpts = { + timePresets: opts.timePresets, + }; + // First paint. const renderToolbar = () => { toolbar.innerHTML = ""; for (const axis of opts.axes) { - const el = renderAxis(axis as AxisKey, ctx); + const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts); if (el) toolbar.appendChild(el); } if (showSave) { diff --git a/frontend/src/client/filter-bar/types.ts b/frontend/src/client/filter-bar/types.ts index c9f3fbf..abdd29e 100644 --- a/frontend/src/client/filter-bar/types.ts +++ b/frontend/src/client/filter-bar/types.ts @@ -57,7 +57,7 @@ export interface BarState { } export interface TimeOverlay { - horizon: "next_7d" | "next_30d" | "next_90d" | "past_30d" | "past_90d" | "any" | "all" | "custom"; + horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom"; from?: string; // ISO 8601 — only when horizon === "custom" to?: string; } @@ -98,10 +98,23 @@ export interface MountOpts { showSaveAsView?: boolean; // Slug of the surface's underlying system view (or saved user view). - // POSTed to /api/views/{slug}/run with the override body. Required — - // the bar runs through that endpoint, never the ad-hoc /api/views/run, - // so the substrate's reserved-slug path stays the canonical entry. - systemViewSlug: string; + // POSTed to /api/views/{slug}/run with the override body. Required + // unless `customRunner` is supplied — see below. When the bar runs + // through this endpoint it is the substrate's canonical entry. + systemViewSlug?: string; + + // Custom runner. When set, the bar bypasses the substrate POST and + // hands the effective spec to this function instead. Used by surfaces + // that haven't migrated to the substrate yet (Verlauf tab still hits + // /api/projects/{id}/events to keep subtree expansion + cursor + // pagination, t-paliad-170). Must be either this OR systemViewSlug — + // the bar throws if both / neither are provided. + customRunner?: (effective: EffectiveSpec) => Promise; + + // Per-surface override of the time-axis chip presets. Order is + // preserved. Default presets are forward-looking (next_*+past_30d+any) + // — backward-looking surfaces (Verlauf, audit) pass past_*+all here. + timePresets?: NonNullable["horizon"][]; // When true, the bar exposes an "Aktualisieren" affordance that // PATCHes /api/user-views/{userViewId} with the effective spec. diff --git a/frontend/src/client/filter-bar/url-codec.ts b/frontend/src/client/filter-bar/url-codec.ts index 101a4ae..e56ea57 100644 --- a/frontend/src/client/filter-bar/url-codec.ts +++ b/frontend/src/client/filter-bar/url-codec.ts @@ -166,6 +166,7 @@ function parseHorizon(s: string): TimeOverlay["horizon"] | null { case "next_7d": case "next_30d": case "next_90d": + case "past_7d": case "past_30d": case "past_90d": case "any": diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index eea566a..c7a9878 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -2179,6 +2179,7 @@ const translations: Record> = { "views.bar.label.approval_entity": "Art", "views.bar.label.deadline_status": "Frist-Status", "views.bar.label.appointment_type": "Termin-Typ", + "views.bar.label.project_event_kind": "Ereignis", "views.bar.label.shape": "Darstellung", "views.bar.label.density": "Dichte", "views.bar.label.sort": "Sortierung", @@ -2186,8 +2187,11 @@ const translations: Record> = { "views.bar.time.next_7d": "7 Tage", "views.bar.time.next_30d": "30 Tage", "views.bar.time.next_90d": "90 Tage", + "views.bar.time.past_7d": "Letzte 7 T.", "views.bar.time.past_30d": "Letzte 30 T.", + "views.bar.time.past_90d": "Letzte 90 T.", "views.bar.time.any": "Beliebig", + "views.bar.time.all": "Alle Zeit", "views.bar.time.custom": "Anpassen", "views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.", "views.bar.personal.on": "Nur eigene", @@ -4372,6 +4376,7 @@ const translations: Record> = { "views.bar.label.approval_entity": "Kind", "views.bar.label.deadline_status": "Deadline status", "views.bar.label.appointment_type": "Appointment type", + "views.bar.label.project_event_kind": "Event", "views.bar.label.shape": "Display", "views.bar.label.density": "Density", "views.bar.label.sort": "Sort", @@ -4379,8 +4384,11 @@ const translations: Record> = { "views.bar.time.next_7d": "7 days", "views.bar.time.next_30d": "30 days", "views.bar.time.next_90d": "90 days", + "views.bar.time.past_7d": "Past 7d", "views.bar.time.past_30d": "Past 30 d.", + "views.bar.time.past_90d": "Past 90 d.", "views.bar.time.any": "Any", + "views.bar.time.all": "All time", "views.bar.time.custom": "Custom", "views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.", "views.bar.personal.on": "Mine only", diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 6619a58..9116fda 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -9,6 +9,8 @@ import { prefillForm, readPayload, } from "./project-form"; +import { mountFilterBar, type BarHandle } from "./filter-bar"; +import type { FilterSpec, RenderSpec } from "./views/types"; interface Project { id: string; @@ -222,6 +224,56 @@ const EVENTS_PAGE_SIZE = 50; let eventsHasMore = false; let eventsLoadingMore = false; +// t-paliad-170 — Verlauf FilterBar state. +// +// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and +// drives loadEvents through its customRunner. Filtering is client-side +// against the legacy /api/projects/{id}/events response so subtree mode +// + cursor pagination stay intact (substrate-side scope expansion lands +// with t-paliad-169 SmartTimeline). Empty filter → identity passthrough. +let verlaufBar: BarHandle | null = null; +interface VerlaufFilters { + eventKinds?: Set; + // Bounds are inclusive lower / exclusive upper, matching + // computeViewSpecBounds in internal/services/view_service.go so the + // semantics align when this surface eventually moves to the substrate. + fromDate?: Date; + toDate?: Date; +} +let verlaufFilters: VerlaufFilters = {}; + +function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] { + const f = verlaufFilters; + if (!f.eventKinds && !f.fromDate && !f.toDate) return rows; + return rows.filter((r) => { + if (f.eventKinds && !f.eventKinds.has(r.event_type ?? "")) return false; + const created = new Date(r.created_at); + if (f.fromDate && created < f.fromDate) return false; + if (f.toDate && created >= f.toDate) return false; + return true; + }); +} + +// horizonBounds mirrors computeViewSpecBounds in view_service.go for the +// horizons that show up on the Verlauf bar. Forward-looking horizons +// (next_*) are absent on this surface — the timePresets override hides +// them — but the function tolerates them for forward-compatibility with +// the SmartTimeline redesign. +function horizonBounds(horizon: string): { from?: Date; to?: Date } { + const now = new Date(); + const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const offset = (days: number) => new Date(day.getTime() + days * 86400000); + switch (horizon) { + case "past_7d": return { from: offset(-7), to: offset(1) }; + case "past_30d": return { from: offset(-30), to: offset(1) }; + case "past_90d": return { from: offset(-90), to: offset(1) }; + case "next_7d": return { from: day, to: offset(7) }; + case "next_30d": return { from: day, to: offset(30) }; + case "next_90d": return { from: day, to: offset(90) }; + default: return {}; + } +} + // Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine, // Verlauf show rows from this project AND all descendant projects with an // attribution chip per non-direct row. URL param `?subtree=false` flips to @@ -302,27 +354,42 @@ function subtreeParam(): string { return subtreeMode ? "" : "&direct_only=true"; } +// rawEventsCursor tracks the last *raw* (pre-filter) event ID returned by +// the legacy endpoint so cursor pagination keeps working when filters +// drop most rows from a page. Without it, "Mehr laden" with a tight +// filter could stall because events[] (post-filter) wouldn't reach back +// to the actual pagination boundary. +let rawEventsLastID: string | null = null; +let rawEventsLastPageFull = false; + async function loadEvents(id: string) { try { const resp = await fetch( `/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`, ); if (resp.ok) { - events = (await resp.json()) ?? []; - eventsHasMore = events.length === EVENTS_PAGE_SIZE; + const raw: ProjectEvent[] = (await resp.json()) ?? []; + rawEventsLastID = raw.length ? raw[raw.length - 1].id : null; + rawEventsLastPageFull = raw.length === EVENTS_PAGE_SIZE; + events = applyVerlaufFilters(raw); + eventsHasMore = rawEventsLastPageFull; } else { events = []; + rawEventsLastID = null; + rawEventsLastPageFull = false; eventsHasMore = false; } } catch { events = []; + rawEventsLastID = null; + rawEventsLastPageFull = false; eventsHasMore = false; } } async function loadMoreEvents(id: string) { - if (eventsLoadingMore || !eventsHasMore || events.length === 0) return; - const cursor = events[events.length - 1].id; + if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return; + const cursor = rawEventsLastID; const btn = document.getElementById("project-events-loadmore") as HTMLButtonElement | null; eventsLoadingMore = true; if (btn) { @@ -335,8 +402,10 @@ async function loadMoreEvents(id: string) { ); if (resp.ok) { const page: ProjectEvent[] = await resp.json(); - events = events.concat(page); - eventsHasMore = page.length === EVENTS_PAGE_SIZE; + rawEventsLastID = page.length ? page[page.length - 1].id : rawEventsLastID; + rawEventsLastPageFull = page.length === EVENTS_PAGE_SIZE; + events = events.concat(applyVerlaufFilters(page)); + eventsHasMore = rawEventsLastPageFull; } } catch { /* swallow — the button re-enables and the user can retry */ @@ -1294,6 +1363,11 @@ async function main() { return; } + // loadEvents stays in this Promise.all so the unfiltered Verlauf is + // ready by first paint (avoids an empty-state flash before the bar's + // customRunner finishes its first run, t-paliad-170). When the URL + // carries filter params (?time=…, ?pe_kind=…) the bar's mount triggers + // a second fetch that narrows to the requested rows — accepted cost. await Promise.all([ loadParties(id), loadEvents(id), @@ -1331,9 +1405,60 @@ async function main() { initSubtreeToggles(id); initAttachUnitForm(id); initNotesContainer(id); + mountVerlaufFilterBar(id); showTab(parseTab()); } +// mountVerlaufFilterBar mounts the universal FilterBar inside the +// Verlauf tab (t-paliad-170). The bar owns URL params (?time=, ?pe_kind=) +// and the displayed filter chrome; on every state change it invokes the +// customRunner below, which calls loadEvents (the legacy +// /api/projects/{id}/events endpoint) and applies client-side filtering. +// +// Why customRunner instead of the substrate POST: the legacy endpoint +// expands the project's descendant subtree server-side and returns +// cursor-paginated rows, both of which the substrate's project_event +// runner doesn't yet support (substrate only does ScopeExplicit on a +// flat ID list, no "include descendants", no cursor). Migrating to the +// substrate is the SmartTimeline redesign (t-paliad-169) — this slice +// avoids the regression by keeping the data path and wiring the bar as +// a UI primitive on top. +function mountVerlaufFilterBar(id: string): void { + const host = document.getElementById("project-events-filter-bar"); + if (!host) return; + + // Synthetic spec — never reaches the substrate (customRunner short- + // circuits the bar's POST), but the bar's contract requires shapes + // that the substrate validator would accept. Sources / scope mirror + // what a future ProjectHistorySystemView would look like. + const baseFilter: FilterSpec = { + version: 1, + sources: ["project_event"], + scope: { projects: { mode: "explicit", ids: [id] } }, + time: { horizon: "any" }, + }; + const baseRender: RenderSpec = { shape: "list" }; + + verlaufBar = mountFilterBar(host, { + baseFilter, + baseRender, + axes: ["time", "project_event_kind"], + surfaceKey: "project-history", + showSaveAsView: false, + timePresets: ["past_7d", "past_30d", "past_90d", "any"], + customRunner: async (effective) => { + const kinds = effective.filter.predicates?.project_event?.event_types; + verlaufFilters = { + eventKinds: kinds && kinds.length ? new Set(kinds) : undefined, + ...horizonBounds(effective.filter.time?.horizon ?? "any"), + }; + await loadEvents(id); + return { rows: [], inaccessible_project_ids: [] }; + }, + onResult: () => renderEvents(), + }); +} + // initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team // tab (project lead / global_admin only). The select is populated from // /api/partner-units excluding units already attached. @@ -1431,8 +1556,14 @@ function initSubtreeToggles(id: string) { subtreeMode = !subtreeMode; persistSubtreeMode(); refreshLabels(); - await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]); - renderEvents(); + // verlaufBar.refresh() drives loadEvents through the bar's + // 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). + const eventsRefresh = verlaufBar + ? verlaufBar.refresh() + : loadEvents(id).then(() => renderEvents()); + await Promise.all([eventsRefresh, loadDeadlines(id), loadAppointments(id)]); renderDeadlines(); renderAppointments(); }); diff --git a/frontend/src/client/views/types.ts b/frontend/src/client/views/types.ts index 47db8fe..c9bac7f 100644 --- a/frontend/src/client/views/types.ts +++ b/frontend/src/client/views/types.ts @@ -20,7 +20,7 @@ export interface ScopeSpec { export type TimeHorizon = | "next_7d" | "next_30d" | "next_90d" - | "past_30d" | "past_90d" + | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom"; export type TimeField = "auto" | "created_at"; diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index a51d810..3385e37 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1971,6 +1971,7 @@ export type I18nKey = | "views.bar.label.deadline_status" | "views.bar.label.density" | "views.bar.label.personal" + | "views.bar.label.project_event_kind" | "views.bar.label.shape" | "views.bar.label.sort" | "views.bar.label.time" @@ -1991,6 +1992,7 @@ export type I18nKey = | "views.bar.shape.list" | "views.bar.sort.date_asc" | "views.bar.sort.date_desc" + | "views.bar.time.all" | "views.bar.time.any" | "views.bar.time.custom" | "views.bar.time.custom.coming_soon" @@ -1998,6 +2000,8 @@ export type I18nKey = | "views.bar.time.next_7d" | "views.bar.time.next_90d" | "views.bar.time.past_30d" + | "views.bar.time.past_7d" + | "views.bar.time.past_90d" | "views.calendar.mobile_fallback" | "views.col.actor" | "views.col.appointment_type" diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx index daca589..c70d9cf 100644 --- a/frontend/src/projects-detail.tsx +++ b/frontend/src/projects-detail.tsx @@ -89,6 +89,9 @@ export function renderProjectsDetail(): string { Inkl. Unterprojekte + {/* t-paliad-170 — FilterBar Phase 2 slice. Mounted by + projects-detail.ts when the Verlauf tab is active. */} +