Files
paliad/frontend/src/client/views.ts
mAi af073f87da fix(print): default to portrait, opt-in landscape for wide surfaces (t-paliad-233)
The smart-timeline-chart block in global.css declared @page { size: A4
landscape } inside @media print. @page rules are global even when nested
in selectors, so this leaked landscape onto every printed surface in
paliad — not just the chart.

Switch to named-page strategy:

- Default @page { size: A4 portrait; margin: 1.5cm 1.2cm }
- @page paliad-landscape { size: A4 landscape; margin: 1.5cm }
- @media print: body.<surface> { page: paliad-landscape } opts surfaces
  that need width into landscape via per-page body classes

Landscape opt-ins:
- body.page-kostenrechner — wide fee-tier tables
- body.page-projects-chart — horizontal Smart Timeline chart
- body.events-view-calendar — /events Kalender tab (month grid)
- body.views-shape-active-calendar / -timeline — Custom Views shapes
- body.verfahrensablauf-view-timeline — horizontal procedure timeline

Body classes:
- kostenrechner.tsx, projects-chart.tsx, verfahrensablauf.tsx now set
  page-<slug> on body
- verfahrensablauf.ts toggles verfahrensablauf-view-(timeline|columns)
  in initViewToggle
- views.ts toggles views-shape-active-<shape> in setActiveShape (mirrors
  the existing events.ts events-view-* pattern)

General print polish in the universal block (the catch-all at the bottom
of global.css):
- Hide .fab / .fab-button / .edit-mode-handle / .paliadin-widget /
  [data-print-hide] in print
- thead { display: table-header-group } so headers repeat across pages
- tr/th/td page-break-inside: avoid so rows don't split mid-cell
- h1-h6 page-break-after: avoid, orphans/widows: 3 for p/h*/li
- print-color-adjust: exact on brand-coloured headers + status pills
- a[href^="http"]::after content: " (" attr(href) ")" prints external
  URLs after their link text (opt-out via data-print-url="hide")
- body font-size: 11pt for print readability

Verified via Playwright on static dist build that:
- Default surfaces (dashboard, projects, fristenrechner, agenda, admin)
  match no page: rule → portrait
- kostenrechner, projects-chart match the landscape rule
- verfahrensablauf-view-columns → portrait, -view-timeline → landscape
- views-shape-active-list/-cards → portrait, -calendar/-timeline →
  landscape
- /events default (events-view-cards) → portrait, calendar toggle →
  landscape

go build ./... + go test ./internal/... + bun test (99 pass) + bun
run build all clean.
2026-05-21 22:01:46 +02:00

362 lines
13 KiB
TypeScript

import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape, DataSource } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
import { renderTimelineShape } from "./views/shape-timeline-cv";
import type { ChartHandle } from "./views/shape-timeline-chart";
import { mountFilterBar, type BarHandle, type AxisKey } from "./filter-bar";
// /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).
//
// t-paliad-211 — the per-view filter bar (`mountFilterBar`) lives between
// the shape chips and the render hosts. The saved view's filter_spec is
// the baseline; the bar overlays the user's per-session tweaks and POSTs
// `/api/views/{slug}/run` with the effective spec as override (the
// substrate accepts `{filter: ...}` per views.go:283). Axes are picked
// from the spec's data sources so a deadline-only view doesn't expose
// the appointment-type chip cluster and vice versa.
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;
let currentRender: RenderSpec | null = null;
let bar: BarHandle | 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;
currentRender = meta.render;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
mountBar(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;
}
// mountBar wires the filter-bar to the view's saved spec. The bar runs
// the spec through `/api/views/{slug}/run` whenever the user tweaks an
// axis, and the onResult callback re-paints into the active shape host.
function mountBar(meta: ViewMeta): void {
const host = document.getElementById("views-filter-bar");
const toolbar = document.getElementById("views-toolbar");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = false;
if (toolbar) toolbar.hidden = false;
if (host) host.hidden = false;
if (!host) return;
// Tear down any prior bar (re-mount on lang change isn't supported
// here, but a future Phase-2 axis switch may need this).
if (bar) {
bar.destroy();
bar = null;
}
const axes = axesForSources(meta.filter.sources);
// surfaceKey scoped per-view-slug so two views remember their own
// density/sort prefs independently.
const surfaceKey = `views.${meta.slug}`;
bar = mountFilterBar(host, {
baseFilter: meta.filter,
baseRender: meta.render,
axes,
surfaceKey,
systemViewSlug: meta.slug,
// The saved view IS the baseline; "Speichern als Sicht" remains
// available for users who want to fork.
showSaveAsView: !meta.is_system,
userViewId: meta.user_view_id,
onResult: (result, effective) => {
if (loading) loading.hidden = true;
currentRows = result;
currentRender = effective.render;
paintRows(result, effective.render);
},
});
}
// axesForSources picks the filter-bar axes a saved view's data sources
// support. Universal axes (time / personal_only / sort) always render;
// per-source predicates only render when the view's spec actually
// queries that source — otherwise the chip would be a no-op.
function axesForSources(sources: DataSource[]): AxisKey[] {
const set = new Set(sources);
const out: AxisKey[] = ["time"];
if (set.has("deadline")) out.push("deadline_status");
if (set.has("appointment")) out.push("appointment_type");
if (set.has("approval_request")) {
out.push("approval_viewer_role");
out.push("approval_status");
out.push("approval_entity_type");
}
if (set.has("project_event")) out.push("project_event_kind");
out.push("personal_only");
out.push("sort");
return out;
}
function paintRows(result: ViewRunResult, render: RenderSpec): void {
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
if (errorEl) errorEl.hidden = true;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
setActiveShape(null);
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
}
return;
}
if (empty) empty.hidden = true;
setActiveShape(render.shape);
renderShape(render.shape, render, result.rows);
}
function setActiveShape(shape: RenderShape | null): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
const el = document.getElementById(host);
if (el) el.hidden = shape === null ? true : !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
});
// Mirror the active shape on <body> so the print stylesheet can opt
// calendar / timeline into landscape (`@page paliad-landscape`) while
// list / cards stay portrait — t-paliad-233.
for (const s of ["list", "cards", "calendar", "timeline"]) {
document.body.classList.toggle(`views-shape-active-${s}`, shape === s);
}
}
let timelineHandle: ChartHandle | null = null;
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
const host = document.getElementById(`views-shape-${shape}`);
if (!host) return;
// Switching away from timeline → dispose the prior chart handle so we
// don't leak resize listeners / SVG nodes between shape flips.
if (shape !== "timeline" && timelineHandle) {
timelineHandle.dispose();
timelineHandle = null;
}
switch (shape) {
case "list":
renderListShape(host, rows, render);
break;
case "cards":
renderCardsShape(host, rows, render);
break;
case "calendar":
renderCalendarShape(host, rows, render);
break;
case "timeline": {
// Tear down any previous chart inside this host before re-mounting
// (the CV adapter clears chart-host innerHTML on its own, but we
// need to dispose the prior handle's resize/click listeners too).
if (timelineHandle) {
timelineHandle.dispose();
timelineHandle = null;
}
const chartHost = document.getElementById("views-timeline-chart-host");
if (chartHost) {
timelineHandle = renderTimelineShape(chartHost, rows, render);
}
maybeShowTimelineCaveat();
break;
}
}
}
/** First-open caveat banner. sessionStorage flag means the user sees it
* once per browser session — dismissive but not annoying. Design §13.4
* documents the limitation; this is the user-facing surface. */
function maybeShowTimelineCaveat(): void {
const FLAG = "paliad-views-timeline-caveat-dismissed";
const banner = document.getElementById("views-timeline-caveat");
const closeBtn = document.getElementById("views-timeline-caveat-close");
if (!banner) return;
if (sessionStorage.getItem(FLAG) === "1") {
banner.hidden = true;
return;
}
banner.hidden = false;
if (closeBtn && !closeBtn.dataset.bound) {
closeBtn.addEventListener("click", () => {
banner.hidden = true;
try {
sessionStorage.setItem(FLAG, "1");
} catch {
/* sessionStorage may be unavailable in strict modes — silently noop */
}
});
closeBtn.dataset.bound = "1";
}
}
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 (!currentRows || !currentRender) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentRender, shape };
currentRender = overrideRender;
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 */ });
}