Files
paliad/frontend/src/client/views/shape-cards.ts
m fdde9eb754 feat(t-paliad-144 A2): frontend Custom Views UI
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.
2026-05-07 13:15:55 +02:00

119 lines
4.0 KiB
TypeScript

import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-cards: day-grouped chronological cards. Same layout style as the
// existing /agenda timeline; works for any source mix.
export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.cards ?? {};
const groupBy = cfg.group_by ?? "day";
const sort = cfg.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (groupBy === "none") {
host.appendChild(renderCardList(sorted));
return;
}
const groups = groupRows(sorted, groupBy);
for (const [key, items] of groups) {
const section = document.createElement("section");
section.className = "views-cards-day";
const heading = document.createElement("h2");
heading.className = "views-cards-day-heading";
heading.textContent = key;
section.appendChild(heading);
section.appendChild(renderCardList(items));
host.appendChild(section);
}
}
function renderCardList(rows: ViewRow[]): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-cards-list";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-card views-card--${row.kind}`;
const head = document.createElement("div");
head.className = "views-card-head";
const kind = document.createElement("span");
kind.className = "views-card-kind";
kind.textContent = t(("views.kind." + row.kind) as I18nKey);
head.appendChild(kind);
const title = document.createElement("h3");
title.className = "views-card-title";
title.textContent = row.title;
head.appendChild(title);
li.appendChild(head);
const meta = document.createElement("div");
meta.className = "views-card-meta";
const time = document.createElement("span");
time.textContent = formatTime(row.event_date);
meta.appendChild(time);
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-card-project";
proj.textContent = row.project_title;
meta.appendChild(proj);
}
if (row.actor_name) {
const actor = document.createElement("span");
actor.className = "views-card-actor";
actor.textContent = row.actor_name;
meta.appendChild(actor);
}
li.appendChild(meta);
if (row.subtitle) {
const sub = document.createElement("p");
sub.className = "views-card-subtitle";
sub.textContent = row.subtitle;
li.appendChild(sub);
}
ul.appendChild(li);
}
return ul;
}
function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, ViewRow[]]> {
const map = new Map<string, ViewRow[]>();
for (const row of rows) {
const key = bucketKey(row.event_date, groupBy);
const arr = map.get(key);
if (arr) arr.push(row);
else map.set(key, [row]);
}
return Array.from(map.entries());
}
function bucketKey(iso: string, groupBy: "day" | "week"): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (groupBy === "week") {
// Round down to Monday, format as "KW NN, YYYY".
const monday = new Date(d);
const day = monday.getDay() || 7; // Sunday=0 → 7
monday.setDate(monday.getDate() - day + 1);
const yearStart = new Date(Date.UTC(monday.getFullYear(), 0, 1));
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
return `KW ${weekNo}, ${monday.getFullYear()}`;
}
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
}