Merge: t-paliad-170 — FilterBar mounted in /projects/<id> Verlauf tab

riemann's Phase 2 slice on top of own 1faffb6 Phase 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).

ebcda13 from mai/riemann/filterbar-phase-2-slice.
This commit is contained in:
m
2026-05-08 23:23:49 +02:00
11 changed files with 303 additions and 55 deletions

View File

@@ -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
// ----------------------------------------------------------------------

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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":

View File

@@ -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",

View File

@@ -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();
});

View File

@@ -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";

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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)