Merge: t-paliad-170 — FilterBar mounted in /projects/<id> Verlauf tab
riemann's Phase 2 slice on top of own1faffb6Phase 1: the universal <FilterBar> is now in the project Verlauf tab. Filter facets: project_event_kind (chip cluster), time (presets including new HorizonPast7d), personal_only. Empty URL preserves current behaviour (unfiltered list); ?time=past_30d&pe_kind=deadline_created narrows. Two extension points added to the bar primitive (forward-compat with SmartTimeline t-paliad-169 work): - customRunner: lets a host page own the data fetch (Verlauf keeps the legacy /api/projects/{id}/events pipeline so subtree + cursor pagination survive — substrate-side scope-with-descendants stays SmartTimeline territory). - timePresets: opt-in past-only horizon set for backward-looking surfaces (vs the default future-leaning set used on /inbox). 3-way merge with main: clean. fourier's t-paliad-168 + lagrange's SmartTimeline design doc preserved. bun build clean; frontend/dist regenerated. go test internal/... ok on riemann's worktree (filter-bar url-codec + filter_spec tests).ebcda13from mai/riemann/filterbar-phase-2-slice.
This commit is contained in:
@@ -21,12 +21,19 @@ export interface AxisCtx {
|
||||
patch(delta: Partial<BarState>): 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<BarState["time"]>["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<BarState["time"]>["horizon"];
|
||||
|
||||
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||
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.<kind>` 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
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ViewRunResult>;
|
||||
|
||||
// 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<BarState["time"]>["horizon"][];
|
||||
|
||||
// When true, the bar exposes an "Aktualisieren" affordance that
|
||||
// PATCHes /api/user-views/{userViewId} with the effective spec.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -2182,6 +2182,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -2189,8 +2190,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -4378,6 +4382,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -4385,8 +4390,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
|
||||
@@ -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<string>;
|
||||
// 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();
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1974,6 +1974,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"
|
||||
@@ -1994,6 +1995,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"
|
||||
@@ -2001,6 +2003,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"
|
||||
|
||||
@@ -89,6 +89,9 @@ export function renderProjectsDetail(): string {
|
||||
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.
|
||||
|
||||
@@ -114,14 +114,15 @@ type TimeSpec struct {
|
||||
type TimeHorizon string
|
||||
|
||||
const (
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonPast7d TimeHorizon = "past_7d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
)
|
||||
|
||||
type TimeField string
|
||||
@@ -279,7 +280,7 @@ func (s *ScopeSpec) validate() error {
|
||||
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
||||
switch t.Horizon {
|
||||
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
||||
HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
// fine
|
||||
case HorizonAll:
|
||||
// Q26: reject "all" unless scope.projects is explicit. Performance
|
||||
|
||||
@@ -169,6 +169,10 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 90)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonPast7d:
|
||||
from := day.AddDate(0, 0, -7)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonPast30d:
|
||||
from := day.AddDate(0, 0, -30)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
|
||||
Reference in New Issue
Block a user