Compare commits

...

3 Commits

Author SHA1 Message Date
mAi
fe421d917c feat(t-paliad-211): mount filter-bar on Custom Views runner
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.

Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.

Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.

Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
2026-05-18 17:43:52 +02:00
mAi
4c85515e50 feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.

shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.

i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
2026-05-18 17:40:43 +02:00
mAi
eac39456be feat(t-paliad-211): calendar shape adds week + day views and aligned grid
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.

Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.

Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.

Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.

i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
2026-05-18 17:37:38 +02:00
8 changed files with 981 additions and 111 deletions

View File

@@ -839,6 +839,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "Oktober",
"cal.month.10": "November",
"cal.month.11": "Dezember",
"cal.view.month": "Monat",
"cal.view.week": "Woche",
"cal.view.day": "Tag",
"cal.month.prev": "Vorheriger Monat",
"cal.month.next": "Nächster Monat",
"cal.week.prev": "Vorherige Woche",
"cal.week.next": "Nächste Woche",
"cal.day.prev": "Vorheriger Tag",
"cal.day.next": "Nächster Tag",
"cal.day.back_to_month": "Zurück zum Monat",
"cal.day.open_day": "Tagesansicht öffnen",
"cal.day.no_entries": "Keine Einträge an diesem Tag.",
// Akten detail — Fristen tab (Phase E)
@@ -2239,6 +2251,12 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.calendar": "Kalender",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Heranzoomen",
"views.timeline.zoom.out": "Herauszoomen",
"views.timeline.zoom.1y": "±1 J.",
"views.timeline.zoom.2y": "±2 J.",
"views.timeline.zoom.all": "Alles",
"views.save_as": "Als Ansicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
@@ -3408,6 +3426,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "October",
"cal.month.10": "November",
"cal.month.11": "December",
"cal.view.month": "Month",
"cal.view.week": "Week",
"cal.view.day": "Day",
"cal.month.prev": "Previous month",
"cal.month.next": "Next month",
"cal.week.prev": "Previous week",
"cal.week.next": "Next week",
"cal.day.prev": "Previous day",
"cal.day.next": "Next day",
"cal.day.back_to_month": "Back to month",
"cal.day.open_day": "Open day view",
"cal.day.no_entries": "Nothing scheduled this day.",
// Akten detail — Fristen tab (Phase E)
@@ -4792,6 +4822,12 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.calendar": "Calendar",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Zoom in",
"views.timeline.zoom.out": "Zoom out",
"views.timeline.zoom.1y": "±1 yr",
"views.timeline.zoom.2y": "±2 yr",
"views.timeline.zoom.all": "All",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",

View File

@@ -1,16 +1,25 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
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();
@@ -30,6 +39,8 @@ interface ViewMeta {
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
let currentRender: RenderSpec | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
@@ -54,9 +65,10 @@ async function hydrate(): Promise<void> {
return;
}
currentMeta = meta;
currentRender = meta.render;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
mountBar(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
@@ -97,57 +109,97 @@ async function resolveMeta(slug: string): Promise<ViewMeta | null> {
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
// 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");
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) {
setActiveShape(null);
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
}
return;
}
if (empty) empty.hidden = true;
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
setActiveShape(render.shape);
renderShape(render.shape, render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
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 = !host.endsWith("-" + shape);
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);
@@ -223,9 +275,10 @@ 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;
if (!currentRows || !currentRender) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
const overrideRender = { ...currentRender, shape };
currentRender = overrideRender;
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});

View File

@@ -1,14 +1,21 @@
import { t, type I18nKey, getLang } from "../i18n";
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month grid. Toggleable to week-view via per-shape
// config. Mirrors the look of /events?view=calendar but generic across
// sources.
// shape-calendar: month / week / day views. The view switcher is rendered
// inline above the grid; the active view persists in the URL via
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
// shareable deep-link. Each view buckets the same flat ViewRow[] by
// ISO-date — only the rendering differs.
type CalView = "month" | "week" | "day";
const VIEW_PARAM = "cal_view";
const DATE_PARAM = "cal_date";
const MAX_PILLS_PER_MONTH_CELL = 3;
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
const view = cfg.default_view ?? "month";
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
@@ -19,15 +26,121 @@ export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render:
host.appendChild(notice);
}
const initialView = readView(cfg.default_view);
const anchor = readAnchor(rows);
paint(host, rows, anchor, initialView);
}
// paint redraws the calendar in the supplied view + anchor. Called from
// the view switcher and from the day/week navigation buttons. Each paint
// clears the host so we don't leak prior DOM.
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
// Keep the mobile-notice (first child) if present; everything else is
// re-rendered each time.
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
host.innerHTML = "";
if (notice) host.appendChild(notice);
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
writeURL(nextView, nextAnchor);
paint(host, rows, nextAnchor, nextView);
}));
if (view === "month") {
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
writeURL("day", clickedDate);
paint(host, rows, clickedDate, "day");
}));
} else if (view === "week") {
wrap.appendChild(renderWeek(anchor, rows));
} else {
wrap.appendChild(renderDay(anchor, rows));
}
const monthRef = pickMonthAnchor(rows);
wrap.appendChild(renderMonth(monthRef, rows));
host.appendChild(wrap);
}
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
// --- Toolbar -------------------------------------------------------------
function renderToolbar(
view: CalView,
anchor: Date,
onNav: (view: CalView, anchor: Date) => void,
): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
// View switcher: month / week / day chips.
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
onNav(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
// Prev / current-label / next. Step size depends on the view.
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
nav.appendChild(next);
// Day/week view: provide a "Zurück zum Monat" link so users can climb
// back without hunting for the switcher chip.
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => onNav("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
// --- Month view ----------------------------------------------------------
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
@@ -37,20 +150,22 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Weekday headers (Mon-Sun, ISO week).
const weekdayBar = document.createElement("div");
weekdayBar.className = "views-calendar-weekdays";
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
// Single grid with one column-template that the weekday row and the day
// cells share. The header row is added with `grid-column: span 7` so
// it spans the full width above the day grid (laid out below).
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
weekdayBar.appendChild(cell);
grid.appendChild(cell);
}
wrap.appendChild(weekdayBar);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
@@ -63,47 +178,16 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd).
const byDate = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
const key = isoDate(d);
const arr = byDate.get(key);
if (arr) arr.push(row);
else byDate.set(key, [row]);
}
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
const byDate = bucketByDate(rows, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
const dayLabel = document.createElement("div");
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(day);
cell.appendChild(dayLabel);
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, 3);
for (const row of visible) {
const li = document.createElement("li");
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
li.textContent = row.title;
li.title = row.title + (row.project_title ? `${row.project_title}` : "");
ul.appendChild(li);
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
more.className = "views-calendar-pill views-calendar-pill--more";
more.textContent = `+${dayRows.length - visible.length}`;
ul.appendChild(more);
}
cell.appendChild(ul);
}
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
@@ -111,14 +195,269 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
return wrap;
}
function pickMonthAnchor(rows: ViewRow[]): Date {
// Anchor on the first row's month, or "this month" if empty.
function renderMonthCell(
dayDate: Date,
dayNum: number,
dayRows: ViewRow[],
onDayDrill: (d: Date) => void,
): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
// Day-number is a click-target that switches to the day view. We render
// it as a button to keep keyboard semantics; the surrounding cell stays
// a div so it doesn't compete with the inner row anchors.
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) {
ul.appendChild(renderPill(row));
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week view -----------------------------------------------------------
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
const col = renderWeekColumn(day, rows);
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
// No 3-row cap on week / day views — show everything for that day.
const dayRows = filterByDay(rows, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day view ------------------------------------------------------------
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(rows, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering -------------------------------------------------------
function renderPill(row: ViewRow): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = rowHref(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
// Pills are anchors — month-cell day-button click ignores them via
// stopPropagation on the button; cell-level handlers would intercept
// them otherwise.
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = rowHref(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function rowHref(row: ViewRow): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event":
// project_events surface on the project's Verlauf — best we can do
// is link to the project. If no project, leave as a non-link target.
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
// --- Bucketing / date helpers --------------------------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
const out = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return d;
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7; // Mon=0
out.setDate(out.getDate() - offset);
return out;
}
function shift(d: Date, view: CalView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
function isToday(d: Date): boolean {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function isoDate(d: Date): string {
@@ -127,3 +466,60 @@ function isoDate(d: Date): string {
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
// --- URL state -----------------------------------------------------------
function readView(defaultView: CalView | undefined): CalView {
const params = new URLSearchParams(window.location.search);
const raw = params.get(VIEW_PARAM);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return defaultView ?? "month";
}
function readAnchor(rows: ViewRow[]): Date {
const params = new URLSearchParams(window.location.search);
const raw = params.get(DATE_PARAM);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
// No URL anchor — pick the first row's date, or today.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function writeURL(view: CalView, anchor: Date): void {
const url = new URL(window.location.href);
url.searchParams.set(VIEW_PARAM, view);
url.searchParams.set(DATE_PARAM, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -467,6 +467,11 @@ export function paint(
}
// Lane separators — horizontal lines between rows + labels in the gutter.
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
// would bleed into the chart canvas (t-paliad-211).
const labelPadding = 8;
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
@@ -479,13 +484,19 @@ export function paint(
}));
}
if (row.label) {
const labelEl = svg("text", {
class: "chart-lane-label",
x: 8,
y: row.y + row.height / 2 + 4,
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
labelEl.textContent = row.label;
gGrid.appendChild(labelEl);
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}

View File

@@ -7,6 +7,7 @@ import {
} from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
import type { RenderSpec, ViewRow } from "./types";
import { t } from "../i18n";
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
// host for the chart renderer.
@@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types";
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
// existing range presets already provide three meaningful zoom levels.
// Stored in URL as ?tl_zoom=1y|2y|all.
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
const ZOOM_PARAM = "tl_zoom";
export function renderTimelineShape(
host: HTMLElement,
rows: ReadonlyArray<ViewRow>,
@@ -35,21 +42,127 @@ export function renderTimelineShape(
const { events, lanes } = adapt(rows);
const cfg = render.timeline ?? {};
// Resolve the initial zoom: URL > render spec > "1y" default.
const initialZoom = resolveInitialZoom(cfg.range_preset);
// Toolbar lives above the chart in its own row so it doesn't compete
// with the date-axis / lane labels for space.
const toolbar = document.createElement("div");
toolbar.className = "views-timeline-toolbar";
host.appendChild(toolbar);
const chartHost = document.createElement("div");
chartHost.className = "views-timeline-chart-host-inner";
host.appendChild(chartHost);
// The CV adapter has no per-project "id" to fetch live timeline data
// for — we hand mount() a placeholder projectId and the staticData
// pre-loaded array so it skips the project endpoint entirely. If the
// user clicks a mark, the renderer's default click handler still
// resolves /deadlines/{id} / /appointments/{id} from the adapted
// event's id field, so deep-links land on the correct entity page.
return mount(host, {
const handle = mount(chartHost, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
rangePreset: initialZoom,
rangeFrom: cfg.range_from,
rangeTo: cfg.range_to,
});
let currentZoom = initialZoom;
const setZoom = (next: RangePreset) => {
if (next === currentZoom) return;
currentZoom = next;
handle.setRange(next);
writeZoomURL(next);
paintToolbar();
};
const paintToolbar = () => {
toolbar.innerHTML = "";
const zoomGroup = document.createElement("div");
zoomGroup.className = "views-timeline-zoom-group";
const zoomLabel = document.createElement("span");
zoomLabel.className = "views-timeline-zoom-label";
zoomLabel.textContent = t("views.timeline.zoom.label");
zoomGroup.appendChild(zoomLabel);
const zoomOut = document.createElement("button");
zoomOut.type = "button";
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
zoomOut.title = t("views.timeline.zoom.out");
zoomOut.textContent = "";
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
zoomOut.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
});
zoomGroup.appendChild(zoomOut);
// Active-level chips (1y / 2y / all). Clicking jumps directly.
const chips = document.createElement("div");
chips.className = "views-timeline-zoom-chips agenda-chip-row";
for (const level of ZOOM_LEVELS) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-timeline-zoom-chip"
+ (level === currentZoom ? " agenda-chip-active" : "");
chip.dataset.zoom = level;
chip.textContent = t(zoomLevelKey(level));
chip.addEventListener("click", () => setZoom(level));
chips.appendChild(chip);
}
zoomGroup.appendChild(chips);
const zoomIn = document.createElement("button");
zoomIn.type = "button";
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
zoomIn.title = t("views.timeline.zoom.in");
zoomIn.textContent = "+";
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
zoomIn.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
});
zoomGroup.appendChild(zoomIn);
toolbar.appendChild(zoomGroup);
};
paintToolbar();
// Apply the URL zoom if it differed from the spec — mount() already
// used initialZoom so this is a no-op when URL was empty. But when URL
// disagreed with the spec, mount() honoured the URL and the toolbar
// already reflects that, so nothing extra to do here.
return handle;
}
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
if (level === "1y") return "views.timeline.zoom.1y";
if (level === "2y") return "views.timeline.zoom.2y";
return "views.timeline.zoom.all";
}
function resolveInitialZoom(spec: string | undefined): RangePreset {
const params = new URLSearchParams(window.location.search);
const raw = params.get(ZOOM_PARAM);
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
return "1y";
}
function writeZoomURL(zoom: RangePreset): void {
const url = new URL(window.location.href);
url.searchParams.set(ZOOM_PARAM, zoom);
history.replaceState(null, "", url.toString());
}
export interface AdapterResult {

View File

@@ -653,8 +653,13 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"
| "cal.day.next"
| "cal.day.no_entries"
| "cal.day.open_day"
| "cal.day.prev"
| "cal.day.sat"
| "cal.day.sun"
| "cal.day.thu"
@@ -672,6 +677,13 @@ export type I18nKey =
| "cal.month.7"
| "cal.month.8"
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
| "cal.week.next"
| "cal.week.prev"
| "caldav.delete"
| "caldav.delete.confirm"
| "caldav.delete.done"
@@ -2423,6 +2435,12 @@ export type I18nKey =
| "views.source.project_event"
| "views.subtitle"
| "views.timeline.caveat.body"
| "views.timeline.zoom.1y"
| "views.timeline.zoom.2y"
| "views.timeline.zoom.all"
| "views.timeline.zoom.in"
| "views.timeline.zoom.label"
| "views.timeline.zoom.out"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -11773,16 +11773,58 @@ dialog.quick-add-sheet::backdrop {
color: var(--color-text-muted);
}
/* shape=calendar. */
/* shape=calendar. month / week / day views share .views-calendar wrapper;
the variant class .views-calendar--<view> drives any per-view tweaks. */
.views-calendar-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.views-calendar-view-switcher {
display: inline-flex;
gap: 4px;
}
.views-calendar-nav {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-calendar-nav-btn {
min-width: 32px;
padding: 4px 8px;
}
.views-calendar-nav-label {
font-weight: 600;
min-width: 12ch;
text-align: center;
}
.views-calendar-back-to-month {
background: transparent;
border: none;
color: var(--color-link, var(--color-accent));
cursor: pointer;
padding: 4px 8px;
font-size: 13px;
text-decoration: underline;
}
.views-calendar-back-to-month:hover {
color: var(--color-link-hover, var(--color-accent));
}
.views-calendar-month-label {
font-size: 18px;
margin: 0 0 12px 0;
}
.views-calendar-weekdays {
/* Month view — one grid contains both the weekday header row and the day
cells, so they share the same column template (no drift). */
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.views-calendar-weekday {
font-size: 12px;
@@ -11790,11 +11832,7 @@ dialog.quick-add-sheet::backdrop {
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 4px;
}
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
}
.views-calendar-cell {
min-height: 80px;
@@ -11803,15 +11841,32 @@ dialog.quick-add-sheet::backdrop {
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 4px;
}
.views-calendar-cell--out {
background: transparent;
border: 1px dashed var(--color-border);
}
.views-calendar-cell--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-cell-day {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 4px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
align-self: flex-start;
font-weight: 600;
}
.views-calendar-cell-day:hover,
.views-calendar-cell-day:focus-visible {
color: var(--color-text);
text-decoration: underline;
}
.views-calendar-pills {
list-style: none;
@@ -11830,12 +11885,147 @@ dialog.quick-add-sheet::backdrop {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
text-decoration: none;
border: none;
text-align: left;
width: 100%;
cursor: pointer;
}
.views-calendar-pill:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-pill--more {
color: var(--color-text-muted);
text-align: center;
background: transparent;
}
/* Week view — 7 columns, scrollable per column when overflowing. */
.views-calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.views-calendar-week-column {
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 200px;
max-height: 70vh;
overflow: hidden;
}
.views-calendar-week-column--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-week-head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-muted);
font-size: 12px;
}
.views-calendar-week-dow {
text-transform: uppercase;
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.views-calendar-week-dnum {
font-weight: 600;
color: var(--color-text);
}
.views-calendar-week-list {
list-style: none;
margin: 0;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1 1 auto;
}
.views-calendar-week-empty {
margin: 0;
padding: 12px 8px;
color: var(--color-text-muted);
font-size: 12px;
text-align: center;
}
/* Day view — single chronological list. */
.views-calendar-day-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.views-calendar-day-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.views-calendar-day-empty {
margin: 0;
padding: 16px;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
/* Row anchors used by both week and day views. */
.views-calendar-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface);
text-decoration: none;
color: var(--color-text);
}
.views-calendar-row:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex: 0 0 8px;
background: var(--color-text-muted);
}
.views-calendar-row-dot--deadline { background: var(--color-accent); }
.views-calendar-row-dot--appointment { background: #3b82f6; }
.views-calendar-row-dot--project_event { background: #a855f7; }
.views-calendar-row-dot--approval_request { background: #f59e0b; }
.views-calendar-row-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.views-calendar-row-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-calendar-row-meta {
font-size: 12px;
color: var(--color-text-muted);
}
.views-calendar-row--week .views-calendar-row-title {
white-space: normal;
}
.views-calendar-mobile-notice {
margin: 0 0 12px 0;
font-size: 12px;
@@ -14603,7 +14793,14 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-chart .chart-lane-label {
font-size: 0.85rem;
font-weight: 500;
fill: var(--chart-lane-label);
color: var(--chart-lane-label);
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: default;
}
.smart-timeline-chart .chart-today-rule {
stroke: var(--chart-today-rule);
@@ -14713,6 +14910,45 @@ dialog.quick-add-sheet::backdrop {
outline-offset: 2px;
}
/* Custom Views timeline toolbar (t-paliad-211) — zoom controls above the
chart canvas. Stays in flow so it doesn't overlap the SVG date axis. */
.views-timeline-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.views-timeline-zoom-group {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-timeline-zoom-label {
font-size: 12px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.views-timeline-zoom-btn {
min-width: 32px;
padding: 4px 10px;
font-weight: 600;
}
.views-timeline-zoom-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.views-timeline-zoom-chips {
display: inline-flex;
gap: 4px;
}
.views-timeline-chart-host-inner {
/* Reserve a min-height so the loading placeholder doesn't collapse
and the toolbar/chart stack stays predictable. */
min-height: 200px;
}
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
Each palette is a pure data-attribute swap of the --chart-* tokens.
Renderer code never reads palette state — it just emits classed SVG

View File

@@ -60,6 +60,13 @@ export function renderViews(): string {
</a>
</div>
{/* Filter bar host — t-paliad-211. mountFilterBar appends its
own toolbar element here; the saved view's filter_spec
becomes the bar's baseline, axes are chosen client-side
per the view's data sources. */}
<div className="views-filter-bar" id="views-filter-bar" hidden />
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
<div className="views-onboarding" id="views-onboarding" hidden>
<h2 data-i18n="views.onboarding.title">Eigene Ansichten &mdash; was ist das?</h2>