Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.
What lands:
- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
(the editor). One TSX per page; client/views.ts + views-editor.ts
hydrate.
- Three render-shape components in client/views/: shape-list.ts (table
for density=comfortable, compact one-line stream for density=compact —
the activity-feed look without a separate "activity" shape per Q4 lock-
in 2026-05-07), shape-cards.ts (day-grouped chronological), and
shape-calendar.ts (month grid with day-pills, mobile cards-fallback
notice on viewports <600px per design §9 trade-off 8).
- Generic view shell that resolves a slug to a system view (via
/api/views/system) or a user view (via /api/user-views), runs it via
POST /api/views/{slug}/run, dispatches to the matching shape, exposes
a 3-button shape switcher that swaps the live render without re-fetching,
and surfaces the inaccessible-projects toast when the substrate flags
some IDs (Q17 fail-open attribution).
- View editor with widgets for name/slug/icon, sources (4 checkboxes),
scope mode (all_visible / my_subtree / personal_only), time horizon
(six fixed options), shape, and list density. Slug regex enforced
client-side mirroring the server validator. Save → POST/PATCH; delete
→ simple yes/no confirm (Q25 lock-in).
- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
GET /api/user-views on mount, injecting one nav item per saved view
+ an always-present "+ Neue Sicht" trailing entry. show_count=true
views get a sidebar badge updated by a fire-and-forget run query.
- Page handlers /views (most-recently-used redirect or onboarding shell),
/views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.
- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
shape labels, source/kind/horizon/scope vocabulary, editor form,
empty/error/onboarding states.
- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
Meine Sichten sidebar group.
- build.ts registers views.tsx + views-editor.tsx page renderers and
the two client bundles.
Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
252 lines
8.3 KiB
TypeScript
252 lines
8.3 KiB
TypeScript
import { initI18n, t, type I18nKey } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
|
|
import { renderListShape } from "./views/shape-list";
|
|
import { renderCardsShape } from "./views/shape-cards";
|
|
import { renderCalendarShape } from "./views/shape-calendar";
|
|
|
|
// /views and /views/{slug} client. Loads the saved or system view, runs
|
|
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
|
// shape component. Shape-switcher chips toggle the live render without
|
|
// re-fetching (the rows are already in memory).
|
|
|
|
initI18n();
|
|
initSidebar();
|
|
|
|
interface ViewMeta {
|
|
// For saved views: identifies the row for touch/edit/delete.
|
|
user_view_id?: string;
|
|
// Display name + slug.
|
|
name: string;
|
|
slug: string;
|
|
// Filter + render specs (may be overridden by slug detection).
|
|
filter: FilterSpec;
|
|
render: RenderSpec;
|
|
// Whether this is a code-resident SystemView.
|
|
is_system: boolean;
|
|
}
|
|
|
|
let currentMeta: ViewMeta | null = null;
|
|
let currentRows: ViewRunResult | null = null;
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
bindShapeChips();
|
|
bindToastClose();
|
|
void hydrate();
|
|
});
|
|
|
|
async function hydrate(): Promise<void> {
|
|
const slug = pathSlug();
|
|
if (!slug) {
|
|
// /views with no slug → empty / onboarding state.
|
|
const onboarding = document.getElementById("views-onboarding");
|
|
const loading = document.getElementById("views-loading");
|
|
if (loading) loading.hidden = true;
|
|
if (onboarding) onboarding.hidden = false;
|
|
return;
|
|
}
|
|
// Resolve the view: try system first, then user.
|
|
const meta = await resolveMeta(slug);
|
|
if (!meta) {
|
|
showError(t("views.error.not_found"));
|
|
return;
|
|
}
|
|
currentMeta = meta;
|
|
document.title = `${meta.name} — Paliad`;
|
|
updateHeader(meta);
|
|
await runAndRender(meta);
|
|
if (meta.user_view_id) {
|
|
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
|
|
}
|
|
}
|
|
|
|
async function resolveMeta(slug: string): Promise<ViewMeta | null> {
|
|
// Try the system view list first — cheap, code-resident.
|
|
try {
|
|
const r = await fetch("/api/views/system", { credentials: "include" });
|
|
if (r.ok) {
|
|
const list = (await r.json()) as Array<{ Slug: string; Name: string; Filter: FilterSpec; Render: RenderSpec }>;
|
|
const sys = list.find((sv) => sv.Slug === slug);
|
|
if (sys) {
|
|
return { name: sys.Name, slug: sys.Slug, filter: sys.Filter, render: sys.Render, is_system: true };
|
|
}
|
|
}
|
|
} catch (_e) {
|
|
// fall through to user lookup
|
|
}
|
|
// Try a saved user view.
|
|
try {
|
|
const r = await fetch("/api/user-views", { credentials: "include" });
|
|
if (r.ok) {
|
|
const list = (await r.json()) as UserView[];
|
|
const v = list.find((uv) => uv.slug === slug);
|
|
if (v) {
|
|
return {
|
|
user_view_id: v.id,
|
|
name: v.name,
|
|
slug: v.slug,
|
|
filter: v.filter_spec,
|
|
render: v.render_spec,
|
|
is_system: false,
|
|
};
|
|
}
|
|
}
|
|
} catch (_e) { /* noop */ }
|
|
return null;
|
|
}
|
|
|
|
async function runAndRender(meta: ViewMeta): Promise<void> {
|
|
const loading = document.getElementById("views-loading");
|
|
const empty = document.getElementById("views-empty");
|
|
const errorEl = document.getElementById("views-error");
|
|
const toolbar = document.getElementById("views-toolbar");
|
|
if (loading) loading.hidden = false;
|
|
if (empty) empty.hidden = true;
|
|
if (errorEl) errorEl.hidden = true;
|
|
if (toolbar) toolbar.hidden = false;
|
|
|
|
let result: ViewRunResult;
|
|
try {
|
|
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
if (!r.ok) {
|
|
showError(`${r.status}: ${r.statusText}`);
|
|
return;
|
|
}
|
|
result = (await r.json()) as ViewRunResult;
|
|
} catch (e) {
|
|
showError(t("views.error.network"));
|
|
return;
|
|
}
|
|
if (loading) loading.hidden = true;
|
|
|
|
currentRows = result;
|
|
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
|
|
showInaccessibleToast(result.inaccessible_project_ids.length);
|
|
}
|
|
|
|
if (result.rows.length === 0) {
|
|
if (empty) {
|
|
empty.hidden = false;
|
|
const hint = document.getElementById("views-empty-hint");
|
|
if (hint) hint.textContent = filterSummary(meta.filter);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setActiveShape(meta.render.shape);
|
|
renderShape(meta.render.shape, meta.render, result.rows);
|
|
}
|
|
|
|
function setActiveShape(shape: RenderShape): void {
|
|
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
|
|
const el = document.getElementById(host);
|
|
if (el) el.hidden = !host.endsWith("-" + shape);
|
|
}
|
|
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
|
btn.classList.toggle("active", btn.dataset.shape === shape);
|
|
});
|
|
}
|
|
|
|
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
|
|
const host = document.getElementById(`views-shape-${shape}`);
|
|
if (!host) return;
|
|
switch (shape) {
|
|
case "list":
|
|
renderListShape(host, rows, render);
|
|
break;
|
|
case "cards":
|
|
renderCardsShape(host, rows, render);
|
|
break;
|
|
case "calendar":
|
|
renderCalendarShape(host, rows, render);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function bindShapeChips(): void {
|
|
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const shape = (btn.dataset.shape ?? "list") as RenderShape;
|
|
if (!currentMeta || !currentRows) return;
|
|
// Override the shape transiently — doesn't mutate the saved spec.
|
|
const overrideRender = { ...currentMeta.render, shape };
|
|
setActiveShape(shape);
|
|
renderShape(shape, overrideRender, currentRows.rows);
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateHeader(meta: ViewMeta): void {
|
|
const heading = document.getElementById("views-heading");
|
|
if (heading) heading.textContent = meta.name;
|
|
const subtitle = document.getElementById("views-subtitle");
|
|
if (subtitle) subtitle.textContent = filterSummary(meta.filter);
|
|
const actions = document.getElementById("views-header-actions");
|
|
if (actions) {
|
|
actions.innerHTML = "";
|
|
if (!meta.is_system && meta.user_view_id) {
|
|
const editLink = document.createElement("a");
|
|
editLink.href = `/views/${encodeURIComponent(meta.slug)}/edit`;
|
|
editLink.className = "btn-secondary btn-small";
|
|
editLink.textContent = t("views.action.edit");
|
|
actions.appendChild(editLink);
|
|
}
|
|
}
|
|
}
|
|
|
|
function filterSummary(filter: FilterSpec): string {
|
|
const parts: string[] = [];
|
|
// Sources
|
|
parts.push(filter.sources.map((s) => t(("views.source." + s) as I18nKey)).join(" + "));
|
|
// Time
|
|
parts.push(t(("views.horizon." + filter.time.horizon) as I18nKey));
|
|
// Scope
|
|
if (filter.scope.personal_only) {
|
|
parts.push(t("views.scope.personal_only"));
|
|
} else if (filter.scope.projects.mode !== "all_visible") {
|
|
parts.push(t(("views.scope." + filter.scope.projects.mode) as I18nKey));
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
function showError(message: string): void {
|
|
const loading = document.getElementById("views-loading");
|
|
const errorEl = document.getElementById("views-error");
|
|
const msg = document.getElementById("views-error-message");
|
|
if (loading) loading.hidden = true;
|
|
if (errorEl) errorEl.hidden = false;
|
|
if (msg) msg.textContent = message;
|
|
}
|
|
|
|
function showInaccessibleToast(count: number): void {
|
|
const toast = document.getElementById("views-toast");
|
|
const text = document.getElementById("views-toast-text");
|
|
if (!toast || !text) return;
|
|
text.textContent = count === 1
|
|
? t("views.toast.inaccessible_one")
|
|
: t("views.toast.inaccessible_n").replace("{n}", String(count));
|
|
toast.hidden = false;
|
|
}
|
|
|
|
function bindToastClose(): void {
|
|
const close = document.getElementById("views-toast-close");
|
|
const toast = document.getElementById("views-toast");
|
|
if (!close || !toast) return;
|
|
close.addEventListener("click", () => { toast.hidden = true; });
|
|
}
|
|
|
|
function pathSlug(): string | null {
|
|
const m = window.location.pathname.match(/^\/views\/([^\/]+)$/);
|
|
if (!m) return null;
|
|
return decodeURIComponent(m[1]);
|
|
}
|
|
|
|
function fireAndForget(url: string, method: string): void {
|
|
fetch(url, { method, credentials: "include" }).catch(() => { /* noop */ });
|
|
}
|