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.
362 lines
13 KiB
TypeScript
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 */ });
|
|
}
|