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.
119 lines
4.0 KiB
TypeScript
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" });
|
|
}
|