feat(t-paliad-170): mount <FilterBar> in /projects/<id> Verlauf tab
Phase 2 slice of the universal-filter migration (Phase 1 was
t-paliad-163 → /inbox; remaining /agenda /events /deadlines
/appointments stay queued).
What ships:
- FilterBar gains two non-invasive options that future surfaces will
also need:
customRunner — bypass the substrate POST and hand the effective
spec to a surface-supplied runner. Required by
surfaces whose data path can't move to the substrate
yet (Verlauf still uses /api/projects/{id}/events for
subtree expansion + cursor pagination, both absent
from the substrate's project_event runner).
timePresets — per-surface override of the time chip cluster, so
backward-looking surfaces can show past_*+all without
forcing forward-looking next_* chips on every host.
systemViewSlug becomes optional; the bar enforces "exactly one of
customRunner | systemViewSlug" at construction.
- project_event_kind axis renderer (was a null stub) — chip cluster
over KnownProjectEventKinds, labels reuse the existing
event.title.<kind> i18n table so the chip text matches the Verlauf
row title for the same kind.
- HorizonPast7d added end-to-end (substrate validate +
computeViewSpecBounds; FilterBar TimeOverlay + parseHorizon; views
TimeHorizon mirror) so the chip value is valid in every layer when a
later SystemView reuses it.
- Verlauf tab on /projects/<id> mounts the bar with
axes=["time","project_event_kind"], timePresets=
["past_7d","past_30d","past_90d","any"], showSaveAsView=false. The
customRunner reads predicates.project_event.event_types + time.horizon
off the effective spec, sets a verlaufFilters global, and routes
through the legacy loadEvents/loadMoreEvents pipeline (which now
applies the filter set client-side and tracks raw cursor IDs so
"Mehr laden" still walks the underlying pagination boundary even when
most rows get filtered out of a page).
- Subtree toggle drives loadEvents through verlaufBar.refresh() so the
current filter state survives the toggle.
URL state reuses the bar's existing keys (?time=past_30d, ?pe_kind=…).
Empty filter → identity passthrough → current behaviour preserved.
Out of scope (deferred to t-paliad-169 SmartTimeline):
- Migrating Verlauf to the substrate (needs scope-with-descendants)
- Past/future split, dated/undated split, source-track facet
Refs m/paliad#23.
This commit is contained in:
@@ -21,12 +21,19 @@ export interface AxisCtx {
|
|||||||
patch(delta: Partial<BarState>): void;
|
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
|
// renderAxis returns the HTML element for a single axis. The bar's
|
||||||
// mountFilterBar appends the result to its internal toolbar. Returns
|
// mountFilterBar appends the result to its internal toolbar. Returns
|
||||||
// null when the axis is ignored (e.g. surface didn't declare it).
|
// 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) {
|
switch (axis) {
|
||||||
case "time": return renderTimeAxis(ctx);
|
case "time": return renderTimeAxis(ctx, opts?.timePresets);
|
||||||
case "project": return null; // populated lazily — see attachProjectAxis below
|
case "project": return null; // populated lazily — see attachProjectAxis below
|
||||||
case "personal_only": return renderPersonalOnlyAxis(ctx);
|
case "personal_only": return renderPersonalOnlyAxis(ctx);
|
||||||
case "approval_viewer_role": return renderApprovalRoleAxis(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 "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
|
||||||
case "deadline_status": return renderDeadlineStatusAxis(ctx);
|
case "deadline_status": return renderDeadlineStatusAxis(ctx);
|
||||||
case "appointment_type": return renderAppointmentTypeAxis(ctx);
|
case "appointment_type": return renderAppointmentTypeAxis(ctx);
|
||||||
|
case "project_event_kind": return renderProjectEventKindAxis(ctx);
|
||||||
case "shape": return renderShapeAxis(ctx);
|
case "shape": return renderShapeAxis(ctx);
|
||||||
case "density": return renderDensityAxis(ctx);
|
case "density": return renderDensityAxis(ctx);
|
||||||
case "sort": return renderSortAxis(ctx);
|
case "sort": return renderSortAxis(ctx);
|
||||||
|
|
||||||
// Per-source predicates that need their own widgets and a roundtrip
|
// Per-source predicates that need their own widgets and a roundtrip
|
||||||
// through fetched option lists. Phase 2+ will fill these in by
|
// 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 "deadline_event_type":
|
||||||
case "project_event_kind":
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,25 +58,44 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
|
|||||||
// time — chip cluster (presets + Anpassen)
|
// 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 }> = [
|
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||||
{ value: "next_7d", key: "views.bar.time.next_7d" },
|
|
||||||
{ value: "next_30d", key: "views.bar.time.next_30d" },
|
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||||
{ value: "next_90d", key: "views.bar.time.next_90d" },
|
next_7d: "views.bar.time.next_7d",
|
||||||
{ value: "past_30d", key: "views.bar.time.past_30d" },
|
next_30d: "views.bar.time.next_30d",
|
||||||
{ value: "any", key: "views.bar.time.any" },
|
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 wrap = group("views.bar.label.time");
|
||||||
const row = chipRow();
|
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";
|
const current = ctx.get("time")?.horizon ?? "any";
|
||||||
for (const preset of TIME_PRESETS) {
|
for (const preset of presets) {
|
||||||
const chip = chipBtn(t(preset.key), preset.value === current);
|
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", () => {
|
chip.addEventListener("click", () => {
|
||||||
if (preset.value === "any") {
|
if (isUnbounded) {
|
||||||
ctx.patch({ time: undefined });
|
ctx.patch({ time: undefined });
|
||||||
} else {
|
} else {
|
||||||
ctx.patch({ time: { horizon: preset.value } });
|
ctx.patch({ time: { horizon: preset } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.appendChild(chip);
|
row.appendChild(chip);
|
||||||
@@ -249,6 +275,51 @@ function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
|
|||||||
return wrap;
|
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)
|
// shape — segmented control (list / cards / calendar)
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@@ -321,10 +392,6 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
|||||||
return wrap;
|
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
|
// shared helpers — group + chip + row
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
parseBar,
|
parseBar,
|
||||||
encodeBar,
|
encodeBar,
|
||||||
} from "./url-codec";
|
} from "./url-codec";
|
||||||
import { renderAxis, type AxisCtx } from "./axes";
|
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
|
||||||
import { openSaveModal } from "./save-modal";
|
import { openSaveModal } from "./save-modal";
|
||||||
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
||||||
|
|
||||||
@@ -39,6 +39,11 @@ interface PrefsBlob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
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 = {};
|
let state: BarState = {};
|
||||||
const ns = opts.urlNamespace;
|
const ns = opts.urlNamespace;
|
||||||
|
|
||||||
@@ -64,18 +69,25 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
|||||||
lastEffective = effective;
|
lastEffective = effective;
|
||||||
const myVersion = ++runVersion;
|
const myVersion = ++runVersion;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, {
|
let result: ViewRunResult;
|
||||||
method: "POST",
|
if (opts.customRunner) {
|
||||||
credentials: "include",
|
result = await opts.customRunner(effective);
|
||||||
headers: { "Content-Type": "application/json" },
|
} else {
|
||||||
body: JSON.stringify({ filter: effective.filter }),
|
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
|
||||||
});
|
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
|
||||||
if (myVersion !== runVersion) return; // a newer click superseded us
|
method: "POST",
|
||||||
if (!r.ok) {
|
credentials: "include",
|
||||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
headers: { "Content-Type": "application/json" },
|
||||||
return;
|
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);
|
opts.onResult(result, effective);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
if (myVersion !== runVersion) return;
|
if (myVersion !== runVersion) return;
|
||||||
@@ -104,11 +116,15 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const axisRenderOpts: RenderAxisOpts = {
|
||||||
|
timePresets: opts.timePresets,
|
||||||
|
};
|
||||||
|
|
||||||
// First paint.
|
// First paint.
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
toolbar.innerHTML = "";
|
toolbar.innerHTML = "";
|
||||||
for (const axis of opts.axes) {
|
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 (el) toolbar.appendChild(el);
|
||||||
}
|
}
|
||||||
if (showSave) {
|
if (showSave) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface BarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeOverlay {
|
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"
|
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||||
to?: string;
|
to?: string;
|
||||||
}
|
}
|
||||||
@@ -98,10 +98,23 @@ export interface MountOpts {
|
|||||||
showSaveAsView?: boolean;
|
showSaveAsView?: boolean;
|
||||||
|
|
||||||
// Slug of the surface's underlying system view (or saved user view).
|
// Slug of the surface's underlying system view (or saved user view).
|
||||||
// POSTed to /api/views/{slug}/run with the override body. Required —
|
// POSTed to /api/views/{slug}/run with the override body. Required
|
||||||
// the bar runs through that endpoint, never the ad-hoc /api/views/run,
|
// unless `customRunner` is supplied — see below. When the bar runs
|
||||||
// so the substrate's reserved-slug path stays the canonical entry.
|
// through this endpoint it is the substrate's canonical entry.
|
||||||
systemViewSlug: string;
|
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
|
// When true, the bar exposes an "Aktualisieren" affordance that
|
||||||
// PATCHes /api/user-views/{userViewId} with the effective spec.
|
// 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_7d":
|
||||||
case "next_30d":
|
case "next_30d":
|
||||||
case "next_90d":
|
case "next_90d":
|
||||||
|
case "past_7d":
|
||||||
case "past_30d":
|
case "past_30d":
|
||||||
case "past_90d":
|
case "past_90d":
|
||||||
case "any":
|
case "any":
|
||||||
|
|||||||
@@ -2179,6 +2179,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"views.bar.label.approval_entity": "Art",
|
"views.bar.label.approval_entity": "Art",
|
||||||
"views.bar.label.deadline_status": "Frist-Status",
|
"views.bar.label.deadline_status": "Frist-Status",
|
||||||
"views.bar.label.appointment_type": "Termin-Typ",
|
"views.bar.label.appointment_type": "Termin-Typ",
|
||||||
|
"views.bar.label.project_event_kind": "Ereignis",
|
||||||
"views.bar.label.shape": "Darstellung",
|
"views.bar.label.shape": "Darstellung",
|
||||||
"views.bar.label.density": "Dichte",
|
"views.bar.label.density": "Dichte",
|
||||||
"views.bar.label.sort": "Sortierung",
|
"views.bar.label.sort": "Sortierung",
|
||||||
@@ -2186,8 +2187,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"views.bar.time.next_7d": "7 Tage",
|
"views.bar.time.next_7d": "7 Tage",
|
||||||
"views.bar.time.next_30d": "30 Tage",
|
"views.bar.time.next_30d": "30 Tage",
|
||||||
"views.bar.time.next_90d": "90 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_30d": "Letzte 30 T.",
|
||||||
|
"views.bar.time.past_90d": "Letzte 90 T.",
|
||||||
"views.bar.time.any": "Beliebig",
|
"views.bar.time.any": "Beliebig",
|
||||||
|
"views.bar.time.all": "Alle Zeit",
|
||||||
"views.bar.time.custom": "Anpassen",
|
"views.bar.time.custom": "Anpassen",
|
||||||
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
|
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
|
||||||
"views.bar.personal.on": "Nur eigene",
|
"views.bar.personal.on": "Nur eigene",
|
||||||
@@ -4372,6 +4376,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"views.bar.label.approval_entity": "Kind",
|
"views.bar.label.approval_entity": "Kind",
|
||||||
"views.bar.label.deadline_status": "Deadline status",
|
"views.bar.label.deadline_status": "Deadline status",
|
||||||
"views.bar.label.appointment_type": "Appointment type",
|
"views.bar.label.appointment_type": "Appointment type",
|
||||||
|
"views.bar.label.project_event_kind": "Event",
|
||||||
"views.bar.label.shape": "Display",
|
"views.bar.label.shape": "Display",
|
||||||
"views.bar.label.density": "Density",
|
"views.bar.label.density": "Density",
|
||||||
"views.bar.label.sort": "Sort",
|
"views.bar.label.sort": "Sort",
|
||||||
@@ -4379,8 +4384,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"views.bar.time.next_7d": "7 days",
|
"views.bar.time.next_7d": "7 days",
|
||||||
"views.bar.time.next_30d": "30 days",
|
"views.bar.time.next_30d": "30 days",
|
||||||
"views.bar.time.next_90d": "90 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_30d": "Past 30 d.",
|
||||||
|
"views.bar.time.past_90d": "Past 90 d.",
|
||||||
"views.bar.time.any": "Any",
|
"views.bar.time.any": "Any",
|
||||||
|
"views.bar.time.all": "All time",
|
||||||
"views.bar.time.custom": "Custom",
|
"views.bar.time.custom": "Custom",
|
||||||
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
|
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
|
||||||
"views.bar.personal.on": "Mine only",
|
"views.bar.personal.on": "Mine only",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
prefillForm,
|
prefillForm,
|
||||||
readPayload,
|
readPayload,
|
||||||
} from "./project-form";
|
} from "./project-form";
|
||||||
|
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||||
|
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -222,6 +224,56 @@ const EVENTS_PAGE_SIZE = 50;
|
|||||||
let eventsHasMore = false;
|
let eventsHasMore = false;
|
||||||
let eventsLoadingMore = 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,
|
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
|
||||||
// Verlauf show rows from this project AND all descendant projects with an
|
// Verlauf show rows from this project AND all descendant projects with an
|
||||||
// attribution chip per non-direct row. URL param `?subtree=false` flips to
|
// attribution chip per non-direct row. URL param `?subtree=false` flips to
|
||||||
@@ -302,27 +354,42 @@ function subtreeParam(): string {
|
|||||||
return subtreeMode ? "" : "&direct_only=true";
|
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) {
|
async function loadEvents(id: string) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
||||||
);
|
);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
events = (await resp.json()) ?? [];
|
const raw: ProjectEvent[] = (await resp.json()) ?? [];
|
||||||
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
|
rawEventsLastID = raw.length ? raw[raw.length - 1].id : null;
|
||||||
|
rawEventsLastPageFull = raw.length === EVENTS_PAGE_SIZE;
|
||||||
|
events = applyVerlaufFilters(raw);
|
||||||
|
eventsHasMore = rawEventsLastPageFull;
|
||||||
} else {
|
} else {
|
||||||
events = [];
|
events = [];
|
||||||
|
rawEventsLastID = null;
|
||||||
|
rawEventsLastPageFull = false;
|
||||||
eventsHasMore = false;
|
eventsHasMore = false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
events = [];
|
events = [];
|
||||||
|
rawEventsLastID = null;
|
||||||
|
rawEventsLastPageFull = false;
|
||||||
eventsHasMore = false;
|
eventsHasMore = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMoreEvents(id: string) {
|
async function loadMoreEvents(id: string) {
|
||||||
if (eventsLoadingMore || !eventsHasMore || events.length === 0) return;
|
if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return;
|
||||||
const cursor = events[events.length - 1].id;
|
const cursor = rawEventsLastID;
|
||||||
const btn = document.getElementById("project-events-loadmore") as HTMLButtonElement | null;
|
const btn = document.getElementById("project-events-loadmore") as HTMLButtonElement | null;
|
||||||
eventsLoadingMore = true;
|
eventsLoadingMore = true;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -335,8 +402,10 @@ async function loadMoreEvents(id: string) {
|
|||||||
);
|
);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const page: ProjectEvent[] = await resp.json();
|
const page: ProjectEvent[] = await resp.json();
|
||||||
events = events.concat(page);
|
rawEventsLastID = page.length ? page[page.length - 1].id : rawEventsLastID;
|
||||||
eventsHasMore = page.length === EVENTS_PAGE_SIZE;
|
rawEventsLastPageFull = page.length === EVENTS_PAGE_SIZE;
|
||||||
|
events = events.concat(applyVerlaufFilters(page));
|
||||||
|
eventsHasMore = rawEventsLastPageFull;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* swallow — the button re-enables and the user can retry */
|
/* swallow — the button re-enables and the user can retry */
|
||||||
@@ -1294,6 +1363,11 @@ async function main() {
|
|||||||
return;
|
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([
|
await Promise.all([
|
||||||
loadParties(id),
|
loadParties(id),
|
||||||
loadEvents(id),
|
loadEvents(id),
|
||||||
@@ -1331,9 +1405,60 @@ async function main() {
|
|||||||
initSubtreeToggles(id);
|
initSubtreeToggles(id);
|
||||||
initAttachUnitForm(id);
|
initAttachUnitForm(id);
|
||||||
initNotesContainer(id);
|
initNotesContainer(id);
|
||||||
|
mountVerlaufFilterBar(id);
|
||||||
showTab(parseTab());
|
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
|
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
|
||||||
// tab (project lead / global_admin only). The select is populated from
|
// tab (project lead / global_admin only). The select is populated from
|
||||||
// /api/partner-units excluding units already attached.
|
// /api/partner-units excluding units already attached.
|
||||||
@@ -1431,8 +1556,14 @@ function initSubtreeToggles(id: string) {
|
|||||||
subtreeMode = !subtreeMode;
|
subtreeMode = !subtreeMode;
|
||||||
persistSubtreeMode();
|
persistSubtreeMode();
|
||||||
refreshLabels();
|
refreshLabels();
|
||||||
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
|
// verlaufBar.refresh() drives loadEvents through the bar's
|
||||||
renderEvents();
|
// 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();
|
renderDeadlines();
|
||||||
renderAppointments();
|
renderAppointments();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface ScopeSpec {
|
|||||||
|
|
||||||
export type TimeHorizon =
|
export type TimeHorizon =
|
||||||
| "next_7d" | "next_30d" | "next_90d"
|
| "next_7d" | "next_30d" | "next_90d"
|
||||||
| "past_30d" | "past_90d"
|
| "past_7d" | "past_30d" | "past_90d"
|
||||||
| "any" | "all" | "custom";
|
| "any" | "all" | "custom";
|
||||||
|
|
||||||
export type TimeField = "auto" | "created_at";
|
export type TimeField = "auto" | "created_at";
|
||||||
|
|||||||
@@ -1971,6 +1971,7 @@ export type I18nKey =
|
|||||||
| "views.bar.label.deadline_status"
|
| "views.bar.label.deadline_status"
|
||||||
| "views.bar.label.density"
|
| "views.bar.label.density"
|
||||||
| "views.bar.label.personal"
|
| "views.bar.label.personal"
|
||||||
|
| "views.bar.label.project_event_kind"
|
||||||
| "views.bar.label.shape"
|
| "views.bar.label.shape"
|
||||||
| "views.bar.label.sort"
|
| "views.bar.label.sort"
|
||||||
| "views.bar.label.time"
|
| "views.bar.label.time"
|
||||||
@@ -1991,6 +1992,7 @@ export type I18nKey =
|
|||||||
| "views.bar.shape.list"
|
| "views.bar.shape.list"
|
||||||
| "views.bar.sort.date_asc"
|
| "views.bar.sort.date_asc"
|
||||||
| "views.bar.sort.date_desc"
|
| "views.bar.sort.date_desc"
|
||||||
|
| "views.bar.time.all"
|
||||||
| "views.bar.time.any"
|
| "views.bar.time.any"
|
||||||
| "views.bar.time.custom"
|
| "views.bar.time.custom"
|
||||||
| "views.bar.time.custom.coming_soon"
|
| "views.bar.time.custom.coming_soon"
|
||||||
@@ -1998,6 +2000,8 @@ export type I18nKey =
|
|||||||
| "views.bar.time.next_7d"
|
| "views.bar.time.next_7d"
|
||||||
| "views.bar.time.next_90d"
|
| "views.bar.time.next_90d"
|
||||||
| "views.bar.time.past_30d"
|
| "views.bar.time.past_30d"
|
||||||
|
| "views.bar.time.past_7d"
|
||||||
|
| "views.bar.time.past_90d"
|
||||||
| "views.calendar.mobile_fallback"
|
| "views.calendar.mobile_fallback"
|
||||||
| "views.col.actor"
|
| "views.col.actor"
|
||||||
| "views.col.appointment_type"
|
| "views.col.appointment_type"
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export function renderProjectsDetail(): string {
|
|||||||
Inkl. Unterprojekte
|
Inkl. Unterprojekte
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
<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">
|
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
|
||||||
Noch keine Ereignisse aufgezeichnet.
|
Noch keine Ereignisse aufgezeichnet.
|
||||||
|
|||||||
@@ -114,14 +114,15 @@ type TimeSpec struct {
|
|||||||
type TimeHorizon string
|
type TimeHorizon string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HorizonNext7d TimeHorizon = "next_7d"
|
HorizonNext7d TimeHorizon = "next_7d"
|
||||||
HorizonNext30d TimeHorizon = "next_30d"
|
HorizonNext30d TimeHorizon = "next_30d"
|
||||||
HorizonNext90d TimeHorizon = "next_90d"
|
HorizonNext90d TimeHorizon = "next_90d"
|
||||||
HorizonPast30d TimeHorizon = "past_30d"
|
HorizonPast7d TimeHorizon = "past_7d"
|
||||||
HorizonPast90d TimeHorizon = "past_90d"
|
HorizonPast30d TimeHorizon = "past_30d"
|
||||||
HorizonAny TimeHorizon = "any"
|
HorizonPast90d TimeHorizon = "past_90d"
|
||||||
HorizonAll TimeHorizon = "all"
|
HorizonAny TimeHorizon = "any"
|
||||||
HorizonCustom TimeHorizon = "custom"
|
HorizonAll TimeHorizon = "all"
|
||||||
|
HorizonCustom TimeHorizon = "custom"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TimeField string
|
type TimeField string
|
||||||
@@ -279,7 +280,7 @@ func (s *ScopeSpec) validate() error {
|
|||||||
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
||||||
switch t.Horizon {
|
switch t.Horizon {
|
||||||
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
||||||
HorizonPast30d, HorizonPast90d, HorizonAny:
|
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||||
// fine
|
// fine
|
||||||
case HorizonAll:
|
case HorizonAll:
|
||||||
// Q26: reject "all" unless scope.projects is explicit. Performance
|
// Q26: reject "all" unless scope.projects is explicit. Performance
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
|||||||
from := day
|
from := day
|
||||||
to := day.AddDate(0, 0, 90)
|
to := day.AddDate(0, 0, 90)
|
||||||
return viewSpecBounds{from: &from, to: &to}
|
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:
|
case HorizonPast30d:
|
||||||
from := day.AddDate(0, 0, -30)
|
from := day.AddDate(0, 0, -30)
|
||||||
to := day.AddDate(0, 0, 1)
|
to := day.AddDate(0, 0, 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user