Files
paliad/frontend/src/client/dashboard.ts
mAi 4cd2f05d33 fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode
Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.

Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
  pass places hidden widgets after the visible pass — collision-aware
  + cursor-aware so the hidden tray stacks below the active layout
  without ever displacing a visible widget. applyLayout() passes
  includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
  their stored coordinates so un-hiding restores them in place).

Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
  catalog Min/Max + grid bounds on load. Stale rows with W below MinW
  (or above MaxW, or X+W overflowing the grid) heal on the next
  /api/me/dashboard-layout GET and the cleaned spec is persisted
  back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
  exists to recover users who got into a bad state under the old
  rules.

Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
  behaviour (hidden skipped by default, two-pass ordering, multi-
  hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
  SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
  W=0 sentinel, negative X) plus a round-trip Validate guarantee.
2026-05-22 15:53:19 +02:00

2158 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
import { openModal } from "./components/modal";
import {
GRID_COLUMNS,
MAX_ROW_SPAN,
placeWidgets,
clampW as gridClampW,
clampH as gridClampH,
type PlacedRect,
type WidgetPlacementInput,
type WidgetSizeBound,
} from "./dashboard-grid";
interface DashboardUser {
id: string;
email: string;
display_name: string;
office: string;
role: string;
global_role?: string;
}
interface DeadlineSummary {
overdue: number;
today: number;
this_week: number;
next_week: number;
later: number;
}
interface MatterSummary {
active: number;
archived: number;
total: number;
}
interface UpcomingDeadline {
id: string;
title: string;
due_date: string;
project_id: string;
project_title: string;
project_reference: string;
urgency: "overdue" | "today" | "urgent" | "soon";
}
interface UpcomingAppointment {
id: string;
title: string;
start_at: string;
end_at: string | null;
type: string | null;
project_id: string | null;
project_title: string | null;
project_reference: string | null;
}
interface ActivityEntry {
timestamp: string;
actor_email: string | null;
actor_name: string | null;
project_id: string;
project_title: string;
project_reference: string;
action: string | null;
details: string;
description: string | null;
metadata: Record<string, unknown> | null;
}
interface DashboardData {
user: DashboardUser | null;
deadline_summary: DeadlineSummary;
matter_summary: MatterSummary;
upcoming_deadlines: UpcomingDeadline[];
upcoming_appointments: UpcomingAppointment[];
recent_activity: ActivityEntry[];
inbox_summary?: InboxSummary;
pinned_projects?: PinnedProjectRef[];
}
interface PinnedProjectRef {
project_id: string;
project_title: string;
project_reference: string;
}
interface InboxEntry {
id: string;
entity_type: string;
entity_title?: string | null;
project_id: string;
project_title: string;
requested_at: string;
requester_id: string;
requester_name: string;
}
interface InboxSummary {
pending_count: number;
top: InboxEntry[];
}
// DashboardLayoutSpec mirrors the Go shape in
// internal/services/dashboard_layout_spec.go. The client treats the spec
// as advice: unknown widget keys are dropped silently (server is the
// source of truth for the catalog).
//
// Position fields (x/y/w/h) drive the 12-column grid. Missing values
// default to {x:0, y:auto, w:12, h:1} — pre-overhaul layouts therefore
// render as a single full-width stack until the user customises them.
interface WidgetSettingsValue {
count?: number;
horizon_days?: number;
view?: string;
}
interface DashboardWidgetRef {
key: string;
visible: boolean;
x?: number;
y?: number;
w?: number;
h?: number;
settings?: WidgetSettingsValue;
}
interface DashboardLayoutSpec {
v: number;
widgets: DashboardWidgetRef[];
}
// WidgetDef mirrors services.WidgetDef (Go side). Only the fields the
// client actually reads are typed here; the picker modal needs the
// titles + descriptions in both languages, and the settings schema so
// the gear popover can render the right knobs.
interface ViewOption {
id: string;
label_de: string;
label_en: string;
}
interface WidgetSettingsSchema {
count_options?: number[];
horizon_options?: number[];
count_allows_all?: boolean;
count_max?: number;
horizon_max?: number;
views?: ViewOption[];
}
interface WidgetCatalogEntry {
key: string;
title_de: string;
title_en: string;
description_de: string;
description_en: string;
default_visible: boolean;
default_count?: number;
default_horizon_days?: number;
default_view?: string;
default_w?: number;
default_h?: number;
min_w?: number;
max_w?: number;
min_h?: number;
max_h?: number;
settings?: WidgetSettingsSchema | null;
}
// Grid constants — must match internal/services/dashboard_layout_spec.go.
// Re-exported from ./dashboard-grid so the placement math is shared with
// the unit tests; the names below keep the local imports tidy.
declare global {
interface Window {
__PALIAD_DASHBOARD__?: DashboardData | null;
__PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
__PALIAD_DASHBOARD_CATALOG__?: WidgetCatalogEntry[] | null;
}
}
let currentLayout: DashboardLayoutSpec | null = null;
let currentCatalog: WidgetCatalogEntry[] = [];
let editMode = false;
// Pending PUT debounce — 400ms per design §6.4. Every layout mutation
// (drag, ↑/↓, hide, add, settings change) calls scheduleSave(); only
// the last write inside a 400ms window goes to the wire. A successful
// PUT flashes the .dashboard-save-toast briefly; a failed PUT rolls
// the layout back to its pre-mutation snapshot and surfaces the
// "Speichern fehlgeschlagen" toast so the user can retry.
let saveTimer: number | null = null;
let saveSnapshot: DashboardLayoutSpec | null = null;
const SAVE_DEBOUNCE_MS = 400;
// settingsFor returns the (possibly-empty) settings blob for a given
// widget key in the active layout. Falls back to an empty object so
// renderers can read `.count ?? defaultN` without null checks.
function settingsFor(key: string): WidgetSettingsValue {
if (!currentLayout) return {};
for (const w of currentLayout.widgets) {
if (w.key === key) return w.settings ?? {};
}
return {};
}
// viewFor resolves the active view id for a widget: explicit settings,
// then catalog default, then a single-renderer fallback (empty string).
function viewFor(key: string): string {
const s = settingsFor(key);
if (s.view) return s.view;
const def = lookupCatalog(key);
return def?.default_view ?? "";
}
const POLL_INTERVAL_MS = 60_000;
// 30-day look-ahead matches the agenda.tsx default chip and the server's
// default `to=today+30d` window — keeps the inline agenda visually
// consistent with /agenda when users follow the "full agenda" link.
const AGENDA_LOOKAHEAD_DAYS = 30;
const COLLAPSE_KEY_PREFIX = "paliad:dashboard:collapse:";
let data: DashboardData | null = null;
let agendaItems: AgendaItem[] | null = null;
async function loadDashboard(): Promise<void> {
const unavailable = document.getElementById("dashboard-unavailable")!;
try {
const resp = await fetch("/api/dashboard");
if (resp.status === 503) {
unavailable.style.display = "block";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
return;
}
data = await resp.json();
render();
} catch {
unavailable.style.display = "block";
}
}
function render(): void {
if (!data) return;
renderGreeting(data.user);
renderSummary(data.deadline_summary);
renderMatters(data.matter_summary);
renderDeadlines(data.upcoming_deadlines);
renderAppointments(data.upcoming_appointments);
renderAgenda();
renderActivity(data.recent_activity);
renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
renderPinnedProjects(data.pinned_projects ?? []);
// quick-actions is pure markup — nothing to render dynamically; the
// anchor URLs are baked into dashboard.tsx and only need i18n which
// applyTranslations already handles.
toggleOnboardingHint(data.user);
syncPromoteButtonVisibility(data.user);
// Apply the saved layout AFTER renderers so the per-widget settings
// applied above (count truncation, horizon filtering) are stable
// before we toggle visibility + reorder. Failing to find the layout
// is non-fatal — the factory default markup order takes over.
applyLayout();
}
function renderGreeting(user: DashboardUser | null): void {
const nameEl = document.getElementById("dashboard-greeting-name")!;
const chip = document.getElementById("dashboard-office-chip")!;
const dateEl = document.getElementById("dashboard-date")!;
if (user) {
nameEl.textContent = user.display_name ? `, ${user.display_name}` : "";
const officeLabel = tDyn(`office.${user.office}`) || user.office;
chip.textContent = officeLabel;
chip.className = `dashboard-office-chip office-chip office-${user.office}`;
chip.style.display = "inline-block";
} else {
nameEl.textContent = "";
chip.style.display = "none";
}
const now = new Date();
dateEl.textContent = now.toLocaleDateString(
getLang() === "de" ? "de-DE" : "en-GB",
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
);
}
function renderSummary(s: DeadlineSummary): void {
setCount("dashboard-count-overdue", s.overdue);
setCount("dashboard-count-today", s.today);
setCount("dashboard-count-this-week", s.this_week);
setCount("dashboard-count-next-week", s.next_week);
setCount("dashboard-count-later", s.later);
// Überfällig is an emergency category — hide the card entirely on a clean
// slate (the .dashboard-summary-grid uses auto-fit so the row re-flows to
// 4 cards) and trip the alarm styling when there's anything overdue. See
// t-paliad-105 / t-paliad-106 / t-paliad-110.
const overdueCard = document.getElementById("dashboard-card-overdue")!;
overdueCard.classList.toggle("dashboard-card-overdue-hidden", s.overdue === 0);
overdueCard.classList.toggle("dashboard-card-alarm", s.overdue > 0);
}
function renderMatters(s: MatterSummary): void {
setCount("dashboard-matter-active", s.active);
setCount("dashboard-matter-archived", s.archived);
setCount("dashboard-matter-total", s.total);
}
function renderDeadlines(items: UpcomingDeadline[]): void {
const list = document.getElementById("dashboard-deadlines-list")!;
const empty = document.getElementById("dashboard-deadlines-empty")!;
const cal = document.getElementById("dashboard-deadlines-calendar");
// Per-widget settings: truncate by count + filter by horizon. Backend
// returns 40 rows / 60d; the widget settings narrow it. Defaults match
// the catalog (10 rows, 30 days).
const s = settingsFor("upcoming-deadlines");
items = filterByHorizonDays(items, s.horizon_days ?? 30, (d) => d.due_date);
items = items.slice(0, s.count ?? 10);
const view = viewFor("upcoming-deadlines");
if (view === "calendar" && cal) {
list.innerHTML = "";
list.style.display = "none";
if (!items.length) {
cal.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
cal.style.display = "";
cal.innerHTML = renderMiniCalendar(items.map((d) => ({
date: d.due_date,
label: d.title,
href: `/projects/${d.project_id}/deadlines`,
urgency: d.urgency,
})));
return;
}
if (cal) cal.style.display = "none";
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
list.style.display = "";
empty.style.display = "none";
list.innerHTML = items.map((d) => {
const urgencyClass = `dashboard-urgency-${d.urgency}`;
const urgencyLabel = tDyn(`dashboard.urgency.${d.urgency}`);
return `<li class="dashboard-list-item">
<a href="/projects/${esc(d.project_id)}/deadlines" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(d.title)}</span>
<span class="dashboard-list-ref" title="${escAttr(`${d.project_reference} · ${d.project_title}`)}">${esc(d.project_reference)} &middot; ${esc(d.project_title)}</span>
</div>
<div class="dashboard-list-meta">
<span class="dashboard-urgency-badge ${urgencyClass}" title="${escAttr(urgencyLabel)}">${esc(formatRelative(d.due_date))}</span>
</div>
</a>
</li>`;
}).join("");
}
function renderAppointments(items: UpcomingAppointment[]): void {
const list = document.getElementById("dashboard-appointments-list")!;
const empty = document.getElementById("dashboard-appointments-empty")!;
const cal = document.getElementById("dashboard-appointments-calendar");
const s = settingsFor("upcoming-appointments");
items = filterByHorizonDays(items, s.horizon_days ?? 30, (a) => a.start_at);
items = items.slice(0, s.count ?? 10);
const view = viewFor("upcoming-appointments");
if (view === "calendar" && cal) {
list.innerHTML = "";
list.style.display = "none";
if (!items.length) {
cal.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
cal.style.display = "";
cal.innerHTML = renderMiniCalendar(items.map((a) => ({
// start_at is RFC 3339 — take the date prefix for grouping.
date: a.start_at.slice(0, 10),
label: a.title,
href: a.project_id ? `/projects/${a.project_id}/appointments` : "#",
urgency: undefined,
})));
return;
}
if (cal) cal.style.display = "none";
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
list.style.display = "";
empty.style.display = "none";
list.innerHTML = items.map((a) => {
const dot = a.type
? `<span class="dashboard-termin-dot dashboard-termin-${esc(a.type)}" aria-hidden="true"></span>`
: `<span class="dashboard-termin-dot" aria-hidden="true"></span>`;
const href = a.project_id ? `/projects/${esc(a.project_id)}/appointments` : "#";
const tag = a.project_id ? "a" : "div";
const projectLine = a.project_reference && a.project_title
? `<span class="dashboard-list-ref" title="${escAttr(`${a.project_reference} · ${a.project_title}`)}">${esc(a.project_reference)} &middot; ${esc(a.project_title)}</span>`
: "";
return `<li class="dashboard-list-item">
<${tag} href="${href}" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${dot}${esc(a.title)}</span>
${projectLine}
</div>
<div class="dashboard-list-meta">
<span class="dashboard-appt-time">${esc(formatDateTime(a.start_at))}</span>
</div>
</${tag}>
</li>`;
}).join("");
}
function renderActivity(items: ActivityEntry[]): void {
const list = document.getElementById("dashboard-activity-list")!;
const empty = document.getElementById("dashboard-activity-empty")!;
const s = settingsFor("recent-activity");
items = items.slice(0, s.count ?? 10);
// View toggle: "compact" collapses each event to a single line; the
// default "full" view keeps the two-line breakdown with actor + detail.
const compact = viewFor("recent-activity") === "compact";
list.classList.toggle("dashboard-activity-list--compact", compact);
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
list.style.display = "";
empty.style.display = "none";
list.innerHTML = items.map((e) => {
const actor = e.actor_name || e.actor_email || t("dashboard.activity.system");
const shortKey = e.action ? `dashboard.action.short.${e.action}` : "";
const translated = shortKey ? tDyn(shortKey) : "";
const hasI18n = translated !== "" && translated !== shortKey;
const shortAction = hasI18n
? translated
: (e.action || t("dashboard.activity.event"));
// Localize the muted detail line so it speaks DE in DE and EN in EN —
// historical rows carry English nouns inside DE narrative ("Deadline „ok"
// geändert", "Note zu deadline hinzugefügt"); translateEvent parses both
// legacy and new (value-only) shapes.
const stored = e.description ?? (hasI18n ? "" : e.details);
const { description: detail } = translateEvent(e.action, "", stored);
// For checklist_* events with a known instance_id, deep-link the project
// ref straight to the instance — saves a click vs. landing on the project.
// Falls back to /projects/{id} for any other event or when the instance
// ID is missing (older rows pre-metadata, or checklist_deleted).
const ref = activityHref(e);
return `<li class="dashboard-activity-item">
<span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
<div class="dashboard-activity-body">
<p class="dashboard-activity-summary"><strong>${esc(actor)}</strong> ${esc(shortAction)}</p>
<p class="dashboard-activity-detail">
<a href="${escAttr(ref)}" class="dashboard-activity-project">${esc(e.project_reference)}</a>${detail ? ` <span>${esc(detail)}</span>` : ""}
</p>
</div>
</li>`;
}).join("");
// Row-level click handler: clicking anywhere on the row navigates to the
// same target as the inner .dashboard-activity-project link. Inner <a>/
// <button> clicks bubble through unchanged (Cmd-click → new tab still
// works) and text remains selectable — same pattern as .entity-table rows
// (t-098/099) and the project Verlauf cards (t-paliad-103).
list.querySelectorAll<HTMLLIElement>(".dashboard-activity-item").forEach((row) => {
const link = row.querySelector<HTMLAnchorElement>(".dashboard-activity-project");
if (!link) return;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button")) return;
window.location.href = link.href;
});
});
}
// Resolve an activity row to the most-specific deep-link target. Mirrors the
// rules in projects-detail.ts:eventDetailHref so the activity feed and the
// project Verlauf agree on where each event family points. Falls back to the
// owning project page when no metadata is wired (older rows or _deleted/
// deadlines_imported events). Wired families: checklist_*, deadline_*,
// appointment_*, note_created — see t-paliad-097/102.
function activityHref(e: ActivityEntry): string {
const action = e.action ?? "";
const meta = (e.metadata ?? null) as Record<string, unknown> | null;
if (meta) {
if (action.startsWith("checklist_") && action !== "checklist_deleted") {
const id = meta["checklist_instance_id"];
if (typeof id === "string" && id) return `/checklists/instances/${id}`;
}
if (
action.startsWith("deadline_") &&
action !== "deadline_deleted" &&
action !== "deadlines_imported"
) {
const id = meta["deadline_id"];
if (typeof id === "string" && id) return `/deadlines/${id}`;
}
if (action.startsWith("appointment_") && action !== "appointment_deleted") {
const id = meta["appointment_id"];
if (typeof id === "string" && id) return `/appointments/${id}`;
}
if (action === "note_created") {
const apptID = meta["appointment_id"];
if (typeof apptID === "string" && apptID) return `/appointments/${apptID}`;
const deadlineID = meta["deadline_id"];
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${deadlineID}`;
}
}
return `/projects/${e.project_id}`;
}
// Render the inline Agenda section. Items are fetched once on mount via
// loadAgenda(); subsequent re-renders (lang change, dashboard poll) reuse
// the cached array. The dashboard inline agenda is read-only — no chip
// filters, default 30-day window — see CollapsibleSection in
// dashboard.tsx for the surrounding shell.
function renderAgenda(): void {
const timeline = document.getElementById("dashboard-agenda-timeline");
const list = document.getElementById("dashboard-agenda-list");
const empty = document.getElementById("dashboard-agenda-empty");
if (!timeline || !list || !empty) return;
const view = viewFor("inline-agenda");
const useList = view === "list";
if (agendaItems === null) {
timeline.innerHTML = "";
timeline.style.display = "none";
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "none";
return;
}
if (!agendaItems.length) {
timeline.innerHTML = "";
timeline.style.display = "none";
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
if (useList) {
timeline.style.display = "none";
list.style.display = "";
list.innerHTML = agendaItems.map((it: AgendaItem) => {
const href = it.type === "deadline"
? `/deadlines/${esc(it.id)}`
: `/appointments/${esc(it.id)}`;
const when = it.type === "deadline"
? formatRelative(it.due_date || it.date.slice(0, 10))
: formatDateTime(it.date);
return `<li class="dashboard-list-item">
<a href="${escAttr(href)}" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(it.title)}</span>
${it.project_reference ? `<span class="dashboard-list-ref">${esc(it.project_reference)}</span>` : ""}
</div>
<div class="dashboard-list-meta">
<span class="dashboard-appt-time">${esc(when)}</span>
</div>
</a>
</li>`;
}).join("");
} else {
list.style.display = "none";
timeline.style.display = "";
timeline.innerHTML = renderAgendaTimeline(agendaItems);
}
}
// Mini-calendar — a 1-month grid of YYYY-MM-DD cells with item dots.
// Designed for the upcoming-deadlines / upcoming-appointments calendar
// view: shows the current month plus rolls into the next when the
// horizon stretches past month-end. Compact (no week-of-year, no time
// of day) — for full calendar UX users still click "Vollständige
// Agenda öffnen".
interface CalendarItem {
date: string; // YYYY-MM-DD
label: string;
href: string;
urgency?: "overdue" | "today" | "urgent" | "soon";
}
function renderMiniCalendar(items: CalendarItem[]): string {
if (!items.length) return "";
const today = startOfToday();
// Group by date; sort so dots within a day order by appearance.
const byDate = new Map<string, CalendarItem[]>();
for (const it of items) {
if (!it.date) continue;
(byDate.get(it.date) ?? byDate.set(it.date, []).get(it.date)!).push(it);
}
// Range = today's month start to last item's month end (cap +2 months).
const startMonth = new Date(today.getFullYear(), today.getMonth(), 1);
let lastDate = today;
for (const it of items) {
const d = parseDateOnly(it.date);
if (d && d.getTime() > lastDate.getTime()) lastDate = d;
}
const maxMonth = new Date(today.getFullYear(), today.getMonth() + 2, 1);
if (lastDate.getTime() > maxMonth.getTime()) lastDate = maxMonth;
const months: { year: number; month: number }[] = [];
for (
let m = new Date(startMonth);
m.getTime() <= new Date(lastDate.getFullYear(), lastDate.getMonth(), 1).getTime();
m = new Date(m.getFullYear(), m.getMonth() + 1, 1)
) {
months.push({ year: m.getFullYear(), month: m.getMonth() });
}
const dayHeaderKeys = ["mo", "di", "mi", "do", "fr", "sa", "so"];
const dayHeaders = dayHeaderKeys.map((k) => t(`agenda.day.${k}`) || k);
return months.map((m) => renderMonthGrid(m.year, m.month, byDate, today, dayHeaders)).join("");
}
function renderMonthGrid(
year: number,
month: number,
byDate: Map<string, CalendarItem[]>,
today: Date,
dayHeaders: string[],
): string {
const first = new Date(year, month, 1);
// ISO week starts Mon (1) — convert JS Sun=0 / Mon=1 to Mon=0.
const offset = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: string[] = [];
for (let i = 0; i < offset; i++) cells.push(`<div class="dashboard-cal-cell dashboard-cal-cell--blank"></div>`);
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const dayItems = byDate.get(dateStr) ?? [];
const isToday = year === today.getFullYear() && month === today.getMonth() && d === today.getDate();
const cls = ["dashboard-cal-cell"];
if (isToday) cls.push("dashboard-cal-cell--today");
if (dayItems.length) cls.push("dashboard-cal-cell--has-items");
const dots = dayItems.slice(0, 3).map((it) => {
const urgency = it.urgency ? `dashboard-cal-dot--${it.urgency}` : "";
return `<a class="dashboard-cal-dot ${urgency}" href="${escAttr(it.href)}" title="${escAttr(it.label)}"></a>`;
}).join("");
const overflow = dayItems.length > 3 ? `<span class="dashboard-cal-more">+${dayItems.length - 3}</span>` : "";
cells.push(`<div class="${cls.join(" ")}">
<span class="dashboard-cal-num">${d}</span>
<span class="dashboard-cal-dots">${dots}${overflow}</span>
</div>`);
}
const monthLabel = first.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", { year: "numeric", month: "long" });
return `<div class="dashboard-cal-month">
<h4 class="dashboard-cal-title">${esc(monthLabel)}</h4>
<div class="dashboard-cal-grid">
${dayHeaders.map((d) => `<div class="dashboard-cal-day">${esc(d)}</div>`).join("")}
${cells.join("")}
</div>
</div>`;
}
function parseDateOnly(s: string): Date | null {
if (!s) return null;
const d = new Date(s.length === 10 ? s + "T00:00:00" : s);
if (isNaN(d.getTime())) return null;
d.setHours(0, 0, 0, 0);
return d;
}
async function loadAgenda(): Promise<void> {
const s = settingsFor("inline-agenda");
const horizon = s.horizon_days ?? AGENDA_LOOKAHEAD_DAYS;
const from = toAgendaDate(startOfToday());
const to = toAgendaDate(addDays(startOfToday(), horizon - 1));
try {
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
if (!resp.ok) {
// Fail silently — the rest of the dashboard still loads. The
// inline agenda is best-effort: a 503 (DB-less knowledge-platform
// deploy) or 401 (session timed out, should be caught by the
// page-level redirect) just leaves the section empty.
agendaItems = [];
renderAgenda();
return;
}
agendaItems = (await resp.json()) as AgendaItem[];
renderAgenda();
} catch {
agendaItems = [];
renderAgenda();
}
}
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
function toAgendaDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
// Wire collapsible-section toggles. Each .dashboard-section carries a
// data-collapse-key and the SSR markup renders aria-expanded="true" so
// unstyled fallback shows everything; here we restore persisted state and
// attach click handlers. Persistence is per-section via localStorage —
// keys live under paliad:dashboard:collapse:<section> per the brief.
function initCollapsibleSections(): void {
const sections = document.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]");
sections.forEach((section) => {
const key = section.dataset.collapseKey || "";
if (!key) return;
const stored = localStorage.getItem(COLLAPSE_KEY_PREFIX + key);
const collapsed = stored === "true";
applyCollapseState(section, collapsed);
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
if (!toggle) return;
toggle.addEventListener("click", () => {
const nowExpanded = section.getAttribute("aria-expanded") === "true";
const nextCollapsed = nowExpanded; // expanded → collapsing
applyCollapseState(section, nextCollapsed);
try {
localStorage.setItem(COLLAPSE_KEY_PREFIX + key, String(nextCollapsed));
} catch {
// localStorage may be full or disabled (Safari private mode);
// collapse still works for the current page life. Silent.
}
});
});
// Re-localise the toggle aria-labels on language switch so screen
// readers always read the current language. The visible heading text
// is handled by the i18n applyTranslations pass already.
syncCollapseAriaLabels();
}
function applyCollapseState(section: HTMLElement, collapsed: boolean): void {
section.setAttribute("aria-expanded", String(!collapsed));
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
if (toggle) {
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.setAttribute(
"aria-label",
collapsed ? t("dashboard.section.expand") : t("dashboard.section.collapse"),
);
}
}
function syncCollapseAriaLabels(): void {
document
.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]")
.forEach((section) => {
const collapsed = section.getAttribute("aria-expanded") !== "true";
applyCollapseState(section, collapsed);
});
}
function renderInbox(s: InboxSummary): void {
const summary = document.getElementById("dashboard-inbox-summary");
const list = document.getElementById("dashboard-inbox-list");
const empty = document.getElementById("dashboard-inbox-empty");
if (!summary || !list || !empty) return;
const settings = settingsFor("inbox-approvals");
const cap = settings.count ?? 3;
const top = s.top.slice(0, cap);
if (s.pending_count === 0) {
summary.style.display = "none";
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
summary.style.display = "block";
summary.textContent = getLang() === "de"
? `${s.pending_count} offene Freigaben warten auf dich.`
: `${s.pending_count} open approvals are waiting for you.`;
list.style.display = "";
list.innerHTML = top.map((e) => {
const entityLabel = e.entity_type === "deadline"
? tDyn("dashboard.inbox.entity.deadline")
: (e.entity_type === "appointment"
? tDyn("dashboard.inbox.entity.appointment")
: e.entity_type);
const title = e.entity_title || entityLabel;
return `<li class="dashboard-list-item">
<a href="/inbox" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(title)}</span>
<span class="dashboard-list-ref" title="${escAttr(`${e.project_title} · ${e.requester_name}`)}">${esc(e.project_title)} &middot; ${esc(e.requester_name)}</span>
</div>
<div class="dashboard-list-meta">
<span class="dashboard-appt-time">${esc(formatDateTime(e.requested_at))}</span>
</div>
</a>
</li>`;
}).join("");
}
// --- Edit mode (t-paliad-219 Slice B) -------------------------------------
//
// The Anpassen toggle in the dashboard header flips `editMode` and the
// `body.dashboard-editing` class. When on, every visible widget grows
// a chrome strip with drag handle / ↑ / ↓ / hide / gear; an edit
// footer below the activity widget surfaces the picker + reset. CSS
// hides the chrome when off so unstyled fallback (no JS) is fine.
//
// Chrome is rendered dynamically into a `.dashboard-widget__chrome`
// element appended to each [data-widget-key] element. Toggling edit
// mode rebinds handlers each time — the chrome is destroyed on exit
// so view mode has zero edit-related DOM cost.
//
// Persistence is autosave-on-every-change with a 400ms debounce. See
// scheduleSave() / flushSave().
function lookupCatalog(key: string): WidgetCatalogEntry | undefined {
for (const def of currentCatalog) if (def.key === key) return def;
return undefined;
}
function ensureLayout(): DashboardLayoutSpec {
if (currentLayout) return currentLayout;
// Fallback: derive a layout from the catalog so edit-mode mutations
// have a coherent draft even when the server didn't inline one. The
// next PUT seeds the user's row server-side.
currentLayout = {
v: 1,
widgets: currentCatalog
.filter((d) => d.default_visible)
.map((d) => ({ key: d.key, visible: true, settings: defaultSettings(d) })),
};
return currentLayout;
}
function defaultSettings(def: WidgetCatalogEntry): { count?: number; horizon_days?: number } | undefined {
const out: { count?: number; horizon_days?: number } = {};
if (typeof def.default_count === "number") out.count = def.default_count;
if (typeof def.default_horizon_days === "number") out.horizon_days = def.default_horizon_days;
return Object.keys(out).length ? out : undefined;
}
function snapshotLayout(spec: DashboardLayoutSpec): DashboardLayoutSpec {
return JSON.parse(JSON.stringify(spec));
}
function widgetLabel(key: string): string {
const def = lookupCatalog(key);
if (!def) return key;
return getLang() === "de" ? def.title_de : def.title_en;
}
function widgetDescription(key: string): string {
const def = lookupCatalog(key);
if (!def) return "";
return getLang() === "de" ? def.description_de : def.description_en;
}
function initEditToggle(): void {
const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", () => setEditMode(!editMode));
syncEditToggleLabel();
}
function setEditMode(on: boolean): void {
editMode = on;
document.body.classList.toggle("dashboard-editing", on);
const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
if (btn) btn.setAttribute("aria-pressed", String(on));
syncEditToggleLabel();
rebuildEditChrome();
// Close any open gear popover when leaving edit mode.
if (!on) closeGearPopover();
}
function syncEditToggleLabel(): void {
const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.textContent = editMode ? t("dashboard.edit.exit") : t("dashboard.edit.toggle");
}
// rebuildEditChrome removes any existing chrome and, when editMode is
// on, paints fresh chrome onto every [data-widget-key] element + binds
// the handlers. The chrome is dynamic so view-mode DOM stays lean.
function rebuildEditChrome(): void {
document.querySelectorAll<HTMLElement>(".dashboard-widget__chrome").forEach((el) => el.remove());
document.querySelectorAll<HTMLElement>(".dashboard-widget__resize").forEach((el) => el.remove());
document.querySelectorAll<HTMLElement>("[data-widget-key]").forEach((el) => {
el.removeAttribute("draggable");
el.classList.remove("dashboard-widget");
el.classList.remove("dashboard-widget--hidden");
el.classList.remove("dashboard-widget--dragover");
el.classList.remove("dashboard-widget--resizing");
});
if (!editMode) return;
const layout = ensureLayout();
document.querySelectorAll<HTMLElement>("[data-widget-key]").forEach((el) => {
const key = el.dataset.widgetKey || "";
if (!key) return;
const entry = layout.widgets.find((w) => w.key === key);
const visible = entry ? entry.visible : true;
el.classList.add("dashboard-widget");
if (!visible) {
el.classList.add("dashboard-widget--hidden");
// Hidden widgets are display:none in view mode (applyLayout);
// in edit mode we want them visible-but-dimmed so the user can
// un-hide them inline.
el.style.display = "";
}
const def = lookupCatalog(key);
const chrome = buildChrome(key, visible, def);
el.insertBefore(chrome, el.firstChild);
el.setAttribute("draggable", "true");
bindDnDHandlers(el);
if (visible) {
const handle = buildResizeHandle(key, def);
el.appendChild(handle);
}
});
}
function buildChrome(key: string, visible: boolean, def: WidgetCatalogEntry | undefined): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "dashboard-widget__chrome";
const handle = document.createElement("span");
handle.className = "dashboard-widget__handle";
handle.setAttribute("aria-hidden", "true");
handle.title = t("dashboard.edit.drag");
handle.textContent = "⠇⠇"; // ⠇⠇ a tighter drag-glyph than ⋮⋮
const label = document.createElement("span");
label.className = "dashboard-widget__label";
label.textContent = widgetLabel(key);
const actions = document.createElement("span");
actions.className = "dashboard-widget__actions";
const upBtn = chromeButton("↑", t("dashboard.edit.move_up") + ": " + widgetLabel(key), () => moveWidget(key, -1));
const downBtn = chromeButton("↓", t("dashboard.edit.move_down") + ": " + widgetLabel(key), () => moveWidget(key, +1));
const hideBtn = chromeButton(visible ? "×" : "+", t(visible ? "dashboard.edit.hide" : "dashboard.picker.status.hidden") + ": " + widgetLabel(key), () => toggleHidden(key));
hideBtn.classList.add(visible ? "dashboard-widget__hide" : "dashboard-widget__show");
actions.append(upBtn, downBtn, hideBtn);
// Show the gear when ANY knob exists: count, horizon, view, or
// size/position (every widget has a size, so this is effectively
// every widget in edit mode).
const hasSchemaKnobs = !!(def?.settings && (
def.settings.count_options?.length ||
def.settings.horizon_options?.length ||
def.settings.views?.length ||
def.settings.count_max ||
def.settings.horizon_max
));
if (def && (hasSchemaKnobs || true)) {
const gearBtn = chromeButton("⚙", t("dashboard.edit.settings") + ": " + widgetLabel(key), (ev) => openGearPopover(key, ev.currentTarget as HTMLElement));
gearBtn.classList.add("dashboard-widget__gear");
actions.appendChild(gearBtn);
}
wrap.append(handle, label, actions);
return wrap;
}
function chromeButton(glyph: string, label: string, onClick: (ev: MouseEvent) => void): HTMLButtonElement {
const b = document.createElement("button");
b.type = "button";
b.className = "dashboard-widget__btn";
b.setAttribute("aria-label", label);
b.title = label;
b.textContent = glyph;
b.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
});
return b;
}
// --- Layout mutations ---------------------------------------------------
function moveWidget(key: string, delta: -1 | 1): void {
const layout = ensureLayout();
const idx = layout.widgets.findIndex((w) => w.key === key);
if (idx < 0) return;
const target = idx + delta;
if (target < 0 || target >= layout.widgets.length) return;
const tmp = layout.widgets[idx];
layout.widgets[idx] = layout.widgets[target];
layout.widgets[target] = tmp;
afterLayoutMutation();
}
function toggleHidden(key: string): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (entry) {
entry.visible = !entry.visible;
} else {
// Widget not yet in the layout — append visible (re-add path).
const def = lookupCatalog(key);
if (!def) return;
layout.widgets.push({ key, visible: true, settings: defaultSettings(def) });
}
afterLayoutMutation();
}
// reorderViaDnd handles a drop of srcKey onto destKey. Strategy:
// swap the (x, y) coordinates of the two widgets so the dropped widget
// takes destKey's slot and destKey moves to where src came from. This
// keeps widget sizes intact (no implicit resize on drag) and works
// across rows because positions are explicit grid coords, not list
// indices. Falls back to a simple array-order swap when either widget
// lacks resolved coords (defensive — placements should always exist).
function reorderViaDnd(srcKey: string, destKey: string): void {
if (srcKey === destKey) return;
const layout = ensureLayout();
const src = layout.widgets.find((w) => w.key === srcKey);
const dest = layout.widgets.find((w) => w.key === destKey);
if (!src || !dest) return;
const placements = computePlacements(layout.widgets);
const sp = placements.get(srcKey);
const dp = placements.get(destKey);
if (sp && dp) {
src.x = dp.x;
src.y = dp.y;
dest.x = sp.x;
dest.y = sp.y;
// Sort widgets by (y, x) so the persisted array order tracks the
// visual order — keeps the picker + auto-flow fallback in sync.
layout.widgets.sort(compareByPlacement);
} else {
// Defensive fallback: array-order swap.
const srcI = layout.widgets.indexOf(src);
const destI = layout.widgets.indexOf(dest);
layout.widgets[srcI] = dest;
layout.widgets[destI] = src;
}
afterLayoutMutation();
}
function compareByPlacement(a: DashboardWidgetRef, b: DashboardWidgetRef): number {
const ay = a.y ?? 0, by = b.y ?? 0;
if (ay !== by) return ay - by;
return (a.x ?? 0) - (b.x ?? 0);
}
// resizeWidget commits a new (w, h) to a widget. Sizes are clamped
// against the catalog's min/max so we never persist an out-of-schema
// size — the validator would reject it on PUT and the user would lose
// the gesture.
function resizeWidget(key: string, newW: number, newH: number): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
const def = lookupCatalog(key);
entry.w = clampW(newW, def);
entry.h = clampH(newH, def);
// Re-pack: if the resize pushes x+w past the right edge of the grid,
// shove the widget left so the layout stays inside [0, GRID_COLUMNS).
if (typeof entry.x === "number" && entry.x + entry.w > GRID_COLUMNS) {
entry.x = Math.max(0, GRID_COLUMNS - entry.w);
}
afterLayoutMutation();
}
// moveWidgetToPosition explicitly sets a widget's (x, y). Used by the
// gear popover's position spinners.
function moveWidgetToPosition(key: string, x: number, y: number): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
const def = lookupCatalog(key);
const w = clampW(entry.w ?? def?.default_w ?? GRID_COLUMNS, def);
entry.x = Math.max(0, Math.min(GRID_COLUMNS - w, Math.round(x)));
entry.y = Math.max(0, Math.round(y));
layout.widgets.sort(compareByPlacement);
afterLayoutMutation();
}
function updateWidgetSettings(key: string, patch: { count?: number; horizon_days?: number }): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
entry.settings = { ...(entry.settings ?? {}), ...patch };
afterLayoutMutation();
}
// afterLayoutMutation is the single funnel post-mutation:
// 1. materialise positions so the spec is self-describing (no mixed
// explicit / auto-flow that produces unpredictable renders),
// 2. re-render the widget stack so per-widget settings + ordering
// take effect immediately (count truncation, horizon filtering),
// 3. rebuild edit chrome so the up/down/hide affordances reflect
// the new state,
// 4. schedule a debounced PUT.
function afterLayoutMutation(): void {
materializePositions();
if (data) render();
if (editMode) rebuildEditChrome();
scheduleSave();
}
// materializePositions snapshots computed placements onto the layout
// spec so every visible widget has explicit x/y/w/h on the next render.
// Without this, partial position assignments (e.g. a drag set positions
// on src + dest while other widgets still rely on auto-flow) lead to
// layouts that depend on iteration order — fragile and surprising.
// After materialisation the spec round-trips through the wire without
// losing layout information.
function materializePositions(): void {
const layout = currentLayout;
if (!layout) return;
const placements = computePlacements(layout.widgets);
for (const w of layout.widgets) {
const p = placements.get(w.key);
if (!p) continue;
w.x = p.x;
w.y = p.y;
w.w = p.w;
w.h = p.h;
}
layout.widgets.sort(compareByPlacement);
}
// --- Autosave + reset ---------------------------------------------------
function scheduleSave(): void {
if (!currentLayout) return;
// Take a snapshot on the first dirty edit in a window — rollback
// target for failed saves.
if (saveSnapshot === null) saveSnapshot = snapshotLayout(currentLayout);
if (saveTimer !== null) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveTimer = null;
void flushSave();
}, SAVE_DEBOUNCE_MS);
}
async function flushSave(): Promise<void> {
if (!currentLayout) return;
const payload = snapshotLayout(currentLayout);
const rollback = saveSnapshot;
saveSnapshot = null;
try {
const resp = await fetch("/api/me/dashboard-layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`PUT /api/me/dashboard-layout: ${resp.status}`);
const fresh = (await resp.json()) as DashboardLayoutSpec;
currentLayout = fresh;
if (data) render();
if (editMode) rebuildEditChrome();
flashToast(t("dashboard.edit.saved"), "ok");
} catch (_e) {
if (rollback) currentLayout = rollback;
if (data) render();
if (editMode) rebuildEditChrome();
flashToast(t("dashboard.edit.save_failed"), "err");
}
}
function flashToast(message: string, kind: "ok" | "err"): void {
const el = document.getElementById("dashboard-save-toast");
if (!el) return;
el.textContent = message;
el.classList.remove("dashboard-save-toast--ok", "dashboard-save-toast--err", "dashboard-save-toast--show");
el.classList.add(kind === "ok" ? "dashboard-save-toast--ok" : "dashboard-save-toast--err");
// Force reflow before adding --show so the CSS transition replays
// when toasts fire back-to-back.
void el.offsetWidth;
el.classList.add("dashboard-save-toast--show");
window.setTimeout(() => el.classList.remove("dashboard-save-toast--show"), 1500);
}
function initEditFooter(): void {
const add = document.getElementById("dashboard-edit-add") as HTMLButtonElement | null;
const reset = document.getElementById("dashboard-edit-reset") as HTMLButtonElement | null;
const promote = document.getElementById("dashboard-edit-promote") as HTMLButtonElement | null;
add?.addEventListener("click", openPickerModal);
reset?.addEventListener("click", async () => {
if (!window.confirm(t("dashboard.edit.reset_confirm"))) return;
try {
const resp = await fetch("/api/me/dashboard-layout/reset", { method: "POST" });
if (!resp.ok) throw new Error(`POST reset: ${resp.status}`);
currentLayout = (await resp.json()) as DashboardLayoutSpec;
if (data) render();
if (editMode) rebuildEditChrome();
flashToast(t("dashboard.edit.saved"), "ok");
} catch (_e) {
flashToast(t("dashboard.edit.save_failed"), "err");
}
});
// Promote: admin-only convenience. POSTs to /api/me/dashboard-layout/
// promote which reads the admin's own current layout and stashes it
// into paliad.firm_dashboard_default. Subsequent user reseeds + resets
// land on this layout. Server enforces admin gate; the button hides
// for non-admins via syncPromoteButtonVisibility.
promote?.addEventListener("click", async () => {
if (!window.confirm(t("dashboard.edit.promote_confirm"))) return;
try {
const resp = await fetch("/api/me/dashboard-layout/promote", { method: "POST" });
if (!resp.ok) throw new Error(`POST promote: ${resp.status}`);
flashToast(t("dashboard.edit.promoted"), "ok");
} catch (_e) {
flashToast(t("dashboard.edit.save_failed"), "err");
}
});
}
// --- Picker modal -------------------------------------------------------
function openPickerModal(): void {
const layout = ensureLayout();
const body = document.createElement("div");
body.className = "widget-picker";
const list = document.createElement("ul");
list.className = "widget-picker__list";
body.appendChild(list);
renderPickerList(list, layout);
void openModal<void>({
title: t("dashboard.picker.title"),
body,
primary: { label: t("dashboard.picker.close"), handler: (close) => close() },
secondary: null,
size: "md",
});
}
function renderPickerList(list: HTMLUListElement, layout: DashboardLayoutSpec): void {
list.innerHTML = "";
if (!currentCatalog.length) {
const empty = document.createElement("li");
empty.className = "widget-picker__empty";
empty.textContent = t("dashboard.picker.empty");
list.appendChild(empty);
return;
}
for (const def of currentCatalog) {
const layoutEntry = layout.widgets.find((w) => w.key === def.key);
const status: "active" | "hidden" | "absent" = !layoutEntry
? "absent"
: layoutEntry.visible
? "active"
: "hidden";
const item = document.createElement("li");
item.className = `widget-picker__item widget-picker__item--${status}`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "widget-picker__btn";
btn.disabled = status === "active";
btn.addEventListener("click", () => {
handlePickerAdd(def.key);
renderPickerList(list, ensureLayout());
});
const title = document.createElement("span");
title.className = "widget-picker__title";
title.textContent = widgetLabel(def.key);
const desc = document.createElement("span");
desc.className = "widget-picker__desc";
desc.textContent = widgetDescription(def.key);
const pill = document.createElement("span");
pill.className = `widget-picker__pill widget-picker__pill--${status}`;
pill.textContent = t(
status === "active"
? "dashboard.picker.status.active"
: status === "hidden"
? "dashboard.picker.status.hidden"
: "dashboard.picker.status.absent",
);
btn.append(title, desc, pill);
item.appendChild(btn);
list.appendChild(item);
}
}
function handlePickerAdd(key: string): void {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) {
const def = lookupCatalog(key);
if (!def) return;
layout.widgets.push({ key, visible: true, settings: defaultSettings(def) });
} else if (!entry.visible) {
entry.visible = true;
} else {
return; // already active, no-op
}
afterLayoutMutation();
}
// --- Gear popover -------------------------------------------------------
let openGearAnchor: HTMLElement | null = null;
let openGearPopoverEl: HTMLElement | null = null;
let gearOutsideHandler: ((ev: MouseEvent) => void) | null = null;
let gearKeyHandler: ((ev: KeyboardEvent) => void) | null = null;
function openGearPopover(key: string, anchor: HTMLElement): void {
// Toggle off if the same gear is clicked again.
if (openGearAnchor === anchor) { closeGearPopover(); return; }
closeGearPopover();
const def = lookupCatalog(key);
if (!def) return;
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
const settings = entry?.settings ?? {};
const placement = computePlacements(layout.widgets).get(key);
const pop = document.createElement("div");
pop.className = "dashboard-widget__gear-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("dashboard.edit.settings"));
if (def.settings?.views?.length) {
pop.appendChild(buildViewRow(key, def.settings.views, viewFor(key)));
}
if (def.settings?.count_options?.length || def.settings?.count_max) {
pop.appendChild(buildCountRow(key, def, settings));
}
if (def.settings?.horizon_options?.length || def.settings?.horizon_max) {
pop.appendChild(buildHorizonRow(key, def, settings));
}
// Position + size — always available now that every widget lives in
// the grid. The X spinner is clamped to [0, 12-w]; W/H to the
// widget's MinW/MaxW/MinH/MaxH.
if (placement) {
pop.appendChild(buildSizeRow(key, def, placement));
pop.appendChild(buildPositionRow(key, def, placement));
}
// Position relative to the anchor button. Page coordinates so we
// don't have to chase a scrolling parent. The widget element is the
// positioning context.
const widget = anchor.closest<HTMLElement>("[data-widget-key]") || document.body;
widget.style.position = widget.style.position || "relative";
widget.appendChild(pop);
const rect = anchor.getBoundingClientRect();
const widgetRect = widget.getBoundingClientRect();
pop.style.position = "absolute";
pop.style.top = `${rect.bottom - widgetRect.top + 6}px`;
pop.style.right = `${widgetRect.right - rect.right}px`;
pop.style.zIndex = "20";
openGearAnchor = anchor;
openGearPopoverEl = pop;
gearOutsideHandler = (ev: MouseEvent) => {
const target = ev.target as Node;
if (pop.contains(target) || anchor.contains(target)) return;
closeGearPopover();
};
gearKeyHandler = (ev: KeyboardEvent) => { if (ev.key === "Escape") closeGearPopover(); };
// Defer attach so this click doesn't immediately close us.
window.setTimeout(() => {
document.addEventListener("mousedown", gearOutsideHandler!);
document.addEventListener("keydown", gearKeyHandler!);
}, 0);
}
function closeGearPopover(): void {
if (openGearPopoverEl) openGearPopoverEl.remove();
if (gearOutsideHandler) document.removeEventListener("mousedown", gearOutsideHandler);
if (gearKeyHandler) document.removeEventListener("keydown", gearKeyHandler);
openGearAnchor = null;
openGearPopoverEl = null;
gearOutsideHandler = null;
gearKeyHandler = null;
}
function buildSettingRow(
label: string,
options: number[],
allowsAll: boolean | undefined,
current: number | undefined,
onChange: (value: number) => void,
formatLabel: (n: number) => string,
): HTMLElement {
const row = document.createElement("label");
row.className = "dashboard-widget__gear-row";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = label;
row.appendChild(span);
const sel = document.createElement("select");
sel.className = "dashboard-widget__gear-select";
for (const opt of options) {
const o = document.createElement("option");
o.value = String(opt);
o.textContent = formatLabel(opt);
if (current === opt) o.selected = true;
sel.appendChild(o);
}
if (allowsAll) {
const o = document.createElement("option");
o.value = "-1";
o.textContent = t("dashboard.picker.status.active"); // best effort; "Alle" not in v1 keys
if (current === -1) o.selected = true;
sel.appendChild(o);
}
sel.addEventListener("change", () => onChange(parseInt(sel.value, 10)));
row.appendChild(sel);
return row;
}
// buildCountRow combines a preset dropdown (count_options) and an
// optional free-form numeric input (count_max). Either knob commits via
// updateWidgetSettings({count}); the schema validator accepts both.
function buildCountRow(key: string, def: WidgetCatalogEntry, settings: WidgetSettingsValue): HTMLElement {
const row = document.createElement("div");
row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = t("dashboard.edit.setting.count");
row.appendChild(span);
const controls = document.createElement("span");
controls.className = "dashboard-widget__gear-controls";
if (def.settings?.count_options?.length) {
const sel = document.createElement("select");
sel.className = "dashboard-widget__gear-select";
for (const opt of def.settings.count_options) {
const o = document.createElement("option");
o.value = String(opt);
o.textContent = String(opt);
if (settings.count === opt) o.selected = true;
sel.appendChild(o);
}
if (def.settings.count_allows_all) {
const o = document.createElement("option");
o.value = "-1";
o.textContent = t("dashboard.picker.status.active");
if (settings.count === -1) o.selected = true;
sel.appendChild(o);
}
sel.addEventListener("change", () => updateWidgetSettings(key, { count: parseInt(sel.value, 10) }));
controls.appendChild(sel);
}
if (def.settings?.count_max) {
const input = buildNumberInput(
typeof settings.count === "number" && settings.count > 0 ? settings.count : (def.default_count ?? 1),
1, def.settings.count_max,
(v) => updateWidgetSettings(key, { count: v }),
);
input.title = t("dashboard.edit.setting.count.custom").replace("{n}", String(def.settings.count_max));
controls.appendChild(input);
}
row.appendChild(controls);
return row;
}
function buildHorizonRow(key: string, def: WidgetCatalogEntry, settings: WidgetSettingsValue): HTMLElement {
const row = document.createElement("div");
row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = t("dashboard.edit.setting.horizon");
row.appendChild(span);
const controls = document.createElement("span");
controls.className = "dashboard-widget__gear-controls";
if (def.settings?.horizon_options?.length) {
const sel = document.createElement("select");
sel.className = "dashboard-widget__gear-select";
for (const opt of def.settings.horizon_options) {
const o = document.createElement("option");
o.value = String(opt);
o.textContent = t("dashboard.edit.setting.horizon.days").replace("{n}", String(opt));
if (settings.horizon_days === opt) o.selected = true;
sel.appendChild(o);
}
sel.addEventListener("change", () => updateWidgetSettings(key, { horizon_days: parseInt(sel.value, 10) }));
controls.appendChild(sel);
}
if (def.settings?.horizon_max) {
const input = buildNumberInput(
typeof settings.horizon_days === "number" && settings.horizon_days > 0
? settings.horizon_days
: (def.default_horizon_days ?? 1),
1, def.settings.horizon_max,
(v) => updateWidgetSettings(key, { horizon_days: v }),
);
input.title = t("dashboard.edit.setting.horizon.custom").replace("{n}", String(def.settings.horizon_max));
controls.appendChild(input);
}
row.appendChild(controls);
return row;
}
// buildViewRow renders the per-widget view picker as a segmented control
// (one button per view option). Cheaper to scan than a dropdown when
// most widgets have 2-3 views.
function buildViewRow(key: string, views: ViewOption[], current: string): HTMLElement {
const row = document.createElement("div");
row.className = "dashboard-widget__gear-row";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = t("dashboard.edit.setting.view");
row.appendChild(span);
const group = document.createElement("span");
group.className = "dashboard-widget__view-group";
for (const v of views) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dashboard-widget__view-btn";
btn.dataset.viewId = v.id;
btn.textContent = getLang() === "de" ? v.label_de : v.label_en;
if (v.id === current) btn.classList.add("dashboard-widget__view-btn--active");
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
updateWidgetSettings(key, { view: v.id });
});
group.appendChild(btn);
}
row.appendChild(group);
return row;
}
function buildSizeRow(key: string, def: WidgetCatalogEntry, placement: PlacedRect): HTMLElement {
const row = document.createElement("div");
row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = t("dashboard.edit.setting.size");
row.appendChild(span);
const controls = document.createElement("span");
controls.className = "dashboard-widget__gear-controls";
const minW = def.min_w ?? 1;
const maxW = def.max_w ?? GRID_COLUMNS;
const minH = def.min_h ?? 1;
const maxH = def.max_h ?? MAX_ROW_SPAN;
const wInput = buildNumberInput(placement.w, minW, maxW, (v) => {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
resizeWidget(key, v, entry.h ?? placement.h);
});
wInput.title = "W";
const wLabel = document.createElement("span");
wLabel.className = "dashboard-widget__gear-mini";
wLabel.textContent = "W";
controls.appendChild(wLabel);
controls.appendChild(wInput);
const hInput = buildNumberInput(placement.h, minH, maxH, (v) => {
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
resizeWidget(key, entry.w ?? placement.w, v);
});
hInput.title = "H";
const hLabel = document.createElement("span");
hLabel.className = "dashboard-widget__gear-mini";
hLabel.textContent = "H";
controls.appendChild(hLabel);
controls.appendChild(hInput);
row.appendChild(controls);
return row;
}
function buildPositionRow(key: string, def: WidgetCatalogEntry, placement: PlacedRect): HTMLElement {
const row = document.createElement("div");
row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";
const span = document.createElement("span");
span.className = "dashboard-widget__gear-label";
span.textContent = t("dashboard.edit.setting.position");
row.appendChild(span);
const controls = document.createElement("span");
controls.className = "dashboard-widget__gear-controls";
const xInput = buildNumberInput(placement.x, 0, GRID_COLUMNS - 1, (v) => {
moveWidgetToPosition(key, v, placement.y);
});
xInput.title = "X";
const xLabel = document.createElement("span");
xLabel.className = "dashboard-widget__gear-mini";
xLabel.textContent = "X";
controls.appendChild(xLabel);
controls.appendChild(xInput);
const yInput = buildNumberInput(placement.y, 0, 99, (v) => {
moveWidgetToPosition(key, placement.x, v);
});
yInput.title = "Y";
const yLabel = document.createElement("span");
yLabel.className = "dashboard-widget__gear-mini";
yLabel.textContent = "Y";
controls.appendChild(yLabel);
controls.appendChild(yInput);
// Reference def to silence the unused-param lint — kept in the
// signature so future schemas can clamp position via def.
void def;
row.appendChild(controls);
return row;
}
function buildNumberInput(
current: number,
min: number,
max: number,
onCommit: (v: number) => void,
): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.className = "dashboard-widget__gear-number";
input.min = String(min);
input.max = String(max);
input.step = "1";
input.value = String(current);
// Commit on blur or Enter — avoid firing on every digit.
const commit = () => {
let v = parseInt(input.value, 10);
if (!Number.isFinite(v)) v = current;
v = Math.max(min, Math.min(max, v));
input.value = String(v);
if (v !== current) onCommit(v);
};
input.addEventListener("blur", commit);
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
commit();
input.blur();
}
});
return input;
}
// --- Resize handles -----------------------------------------------------
//
// Each visible widget gets a bottom-right resize affordance in edit mode.
// Pointerdown captures the pointer; pointermove computes the new W/H by
// converting the pointer delta into grid cells (cellW = grid_track_width
// + gap, cellH = rowH + gap, both read live from the grid container) and
// snapping; pointerup commits the size via resizeWidget. We update the
// live element's inline grid-column/row on every move so the user sees
// the resize in real time without a full applyLayout pass.
function buildResizeHandle(key: string, def: WidgetCatalogEntry | undefined): HTMLElement {
const h = document.createElement("span");
h.className = "dashboard-widget__resize";
h.setAttribute("aria-hidden", "true");
h.title = t("dashboard.edit.resize");
// Without draggable=false, pointer-down on the handle inside a
// draggable=true widget would still trigger the parent's HTML5
// dragstart on the first mousemove. Belt + braces: also bail out of
// any in-flight drag on pointerdown.
h.setAttribute("draggable", "false");
h.addEventListener("pointerdown", (ev) => startResize(ev, key, def));
h.addEventListener("dragstart", (ev) => ev.preventDefault());
return h;
}
interface ResizeState {
key: string;
el: HTMLElement;
grid: HTMLElement;
def: WidgetCatalogEntry | undefined;
origW: number;
origH: number;
startX: number;
startY: number;
cellW: number;
cellH: number;
pointerId: number;
}
let activeResize: ResizeState | null = null;
function startResize(ev: PointerEvent, key: string, def: WidgetCatalogEntry | undefined): void {
if (!editMode) return;
ev.preventDefault();
ev.stopPropagation();
const el = (ev.currentTarget as HTMLElement).closest<HTMLElement>("[data-widget-key]");
const grid = document.getElementById("dashboard-grid");
if (!el || !grid) return;
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === key);
if (!entry) return;
const placement = computePlacements(layout.widgets).get(key);
if (!placement) return;
// Cell width = (grid width - 11 gaps) / 12. We read the live grid
// size so a window-resize between renders doesn't desync the snap.
const gridStyle = getComputedStyle(grid);
const gapPx = parseFloat(gridStyle.columnGap || gridStyle.gap || "0") || 0;
const rowGapPx = parseFloat(gridStyle.rowGap || gridStyle.gap || "0") || 0;
const gridWidth = grid.clientWidth;
const cellW = (gridWidth - (GRID_COLUMNS - 1) * gapPx) / GRID_COLUMNS + gapPx;
// Row size is harder — auto-rows can vary. Approximate using the
// widget's current height divided by its current H span.
const rect = el.getBoundingClientRect();
const cellH = (rect.height + rowGapPx) / placement.h;
activeResize = {
key, el, grid, def,
origW: placement.w,
origH: placement.h,
startX: ev.clientX,
startY: ev.clientY,
cellW: Math.max(1, cellW),
cellH: Math.max(1, cellH),
pointerId: ev.pointerId,
};
el.classList.add("dashboard-widget--resizing");
// Disable native DnD on the widget for the duration of the resize so
// pointermove doesn't start a parallel HTML5 drag gesture.
el.removeAttribute("draggable");
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", onResizeEnd);
window.addEventListener("pointercancel", onResizeEnd);
}
function onResizeMove(ev: PointerEvent): void {
if (!activeResize) return;
const dx = ev.clientX - activeResize.startX;
const dy = ev.clientY - activeResize.startY;
const dW = Math.round(dx / activeResize.cellW);
const dH = Math.round(dy / activeResize.cellH);
const newW = clampW(activeResize.origW + dW, activeResize.def);
const newH = clampH(activeResize.origH + dH, activeResize.def);
// Live preview — update the inline style only; don't mutate the
// layout spec until pointerup. Reads current grid-column to keep
// the X-start anchored.
const layout = ensureLayout();
const entry = layout.widgets.find((w) => w.key === activeResize!.key);
if (!entry) return;
const placement = computePlacements(layout.widgets).get(activeResize.key);
if (!placement) return;
// Clamp the live X so the preview doesn't spill past the right edge.
const previewX = Math.min(placement.x, GRID_COLUMNS - newW);
activeResize.el.style.gridColumn = `${previewX + 1} / span ${newW}`;
activeResize.el.style.gridRow = `${placement.y + 1} / span ${newH}`;
}
function onResizeEnd(ev: PointerEvent): void {
if (!activeResize) return;
const dx = ev.clientX - activeResize.startX;
const dy = ev.clientY - activeResize.startY;
const dW = Math.round(dx / activeResize.cellW);
const dH = Math.round(dy / activeResize.cellH);
const newW = clampW(activeResize.origW + dW, activeResize.def);
const newH = clampH(activeResize.origH + dH, activeResize.def);
const state = activeResize;
activeResize = null;
state.el.classList.remove("dashboard-widget--resizing");
// Restore native DnD on the widget — only when still in edit mode.
if (editMode) state.el.setAttribute("draggable", "true");
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", onResizeEnd);
window.removeEventListener("pointercancel", onResizeEnd);
if (newW === state.origW && newH === state.origH) {
// No effective change — re-run applyLayout to snap any preview
// styles back to the canonical positions.
applyLayout();
return;
}
resizeWidget(state.key, newW, newH);
}
// --- Drag and drop ------------------------------------------------------
let dragSourceKey: string | null = null;
function bindDnDHandlers(el: HTMLElement): void {
el.addEventListener("dragstart", (ev) => {
if (!editMode) return;
dragSourceKey = el.dataset.widgetKey || null;
if (ev.dataTransfer && dragSourceKey) {
ev.dataTransfer.setData("text/plain", dragSourceKey);
ev.dataTransfer.effectAllowed = "move";
}
el.classList.add("dashboard-widget--dragging");
});
el.addEventListener("dragover", (ev) => {
if (!editMode || !dragSourceKey) return;
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
el.classList.add("dashboard-widget--dragover");
});
el.addEventListener("dragleave", () => {
el.classList.remove("dashboard-widget--dragover");
});
el.addEventListener("drop", (ev) => {
if (!editMode || !dragSourceKey) return;
ev.preventDefault();
const destKey = el.dataset.widgetKey || "";
el.classList.remove("dashboard-widget--dragover");
if (destKey && dragSourceKey !== destKey) reorderViaDnd(dragSourceKey, destKey);
dragSourceKey = null;
});
el.addEventListener("dragend", () => {
el.classList.remove("dashboard-widget--dragging");
document.querySelectorAll<HTMLElement>(".dashboard-widget--dragover").forEach((d) => d.classList.remove("dashboard-widget--dragover"));
dragSourceKey = null;
});
}
// renderPinnedProjects (Slice C). Backend ships every visible pin (cap
// 20, pinned_at DESC); the widget's count setting trims further.
function renderPinnedProjects(items: PinnedProjectRef[]): void {
const list = document.getElementById("dashboard-pinned-list");
const empty = document.getElementById("dashboard-pinned-empty");
if (!list || !empty) return;
const s = settingsFor("pinned-projects");
const cap = typeof s.count === "number" && s.count > 0 ? s.count : 10;
items = items.slice(0, cap);
if (!items.length) {
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.style.display = "";
list.innerHTML = items.map((p) => {
return `<li class="dashboard-list-item">
<a href="/projects/${esc(p.project_id)}" class="dashboard-list-link">
<div class="dashboard-list-main">
<span class="dashboard-list-title">${esc(p.project_title)}</span>
${p.project_reference ? `<span class="dashboard-list-ref">${esc(p.project_reference)}</span>` : ""}
</div>
<div class="dashboard-list-meta" aria-hidden="true">★</div>
</a>
</li>`;
}).join("");
}
// syncPromoteButtonVisibility shows the "Set as firm default" button
// only to global_admins. The button is rendered in dashboard.tsx with
// style="display:none" so non-admins never see it even if the user
// payload is delayed; once we know the role we either reveal or leave
// it hidden.
function syncPromoteButtonVisibility(user: DashboardUser | null): void {
const btn = document.getElementById("dashboard-edit-promote") as HTMLButtonElement | null;
if (!btn) return;
btn.style.display = user && user.global_role === "global_admin" ? "" : "none";
}
// applyLayout walks the saved DashboardLayoutSpec and (a) hides
// invisible widgets, (b) places visible ones in the 12-column
// .dashboard-grid via grid-column / grid-row inline styles.
//
// Pre-overhaul this function moved widgets between two different
// parents (.container and .dashboard-columns) via appendChild, which
// shoved every widget to the END of its parent — past the edit-footer
// and save-toast — making cross-row drags appear to silently no-op.
// The new contract: every widget is a direct child of .dashboard-grid
// (single source of layout truth) and applyLayout only writes inline
// grid coords, never moves DOM nodes. That kills bug class (a) entirely.
//
// Widgets without explicit positions get an auto-flow placement based
// on their default size — this keeps pre-overhaul layouts (positions
// not yet on the wire) rendering as a sensible stack until the user
// customises them.
function applyLayout(): void {
if (!currentLayout || !Array.isArray(currentLayout.widgets)) return;
const allWidgets = Array.from(
document.querySelectorAll<HTMLElement>("[data-widget-key]"),
);
if (!allWidgets.length) return;
const byKey = new Map<string, HTMLElement>();
allWidgets.forEach((el) => {
const k = el.dataset.widgetKey;
if (k) byKey.set(k, el);
});
// Compute effective placements. In edit mode we also include hidden
// widgets so they render at their stored (or default) dimensions
// dimmed-but-visible — without this they'd inherit no inline grid-
// column and CSS Grid would auto-flow them as 1×1 slivers, producing
// the "super slim greyed-out column" symptom (m/paliad#73). In view
// mode hidden widgets are display:none and reserve no cells.
const placements = computePlacements(currentLayout.widgets, {
includeHidden: editMode,
});
for (const w of currentLayout.widgets) {
const el = byKey.get(w.key);
if (!el) continue;
const placement = placements.get(w.key);
if (editMode) {
el.style.display = "";
} else {
el.style.display = w.visible ? "" : "none";
}
if (placement) {
el.style.gridColumn = `${placement.x + 1} / span ${placement.w}`;
el.style.gridRow = `${placement.y + 1} / span ${placement.h}`;
} else {
el.style.gridColumn = "";
el.style.gridRow = "";
}
}
}
// computePlacements is the local adapter — it walks the layout's widgets,
// resolves each widget's catalog bound, and hands the spec to the pure
// placeWidgets() in ./dashboard-grid. The pure placer carries the no-
// overlap invariant: if two widgets request colliding cells (drag-drop
// swap with mismatched widths, resize-grow into a sibling, etc.) the
// later one is shifted down to the next free row. See m/paliad#70.
//
// includeHidden=true is used by applyLayout in edit mode to also place
// hidden widgets after the visible pass — so the hidden tray renders
// at proper size below the active layout. Default (false) matches the
// persistence + render paths where hidden widgets carry no placement.
function computePlacements(
widgets: DashboardWidgetRef[],
options: { includeHidden?: boolean } = {},
): Map<string, PlacedRect> {
const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
key: w.key,
visible: w.visible,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
bound: toBound(lookupCatalog(w.key)),
}));
return placeWidgets(inputs, options);
}
function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
return gridClampW(w, toBound(def));
}
function clampH(h: number, def: WidgetCatalogEntry | undefined): number {
return gridClampH(h, toBound(def));
}
function toBound(def: WidgetCatalogEntry | undefined): WidgetSizeBound | undefined {
if (!def) return undefined;
return {
default_w: def.default_w,
default_h: def.default_h,
min_w: def.min_w,
max_w: def.max_w,
min_h: def.min_h,
max_h: def.max_h,
};
}
// filterByHorizonDays drops items whose key date is more than `days`
// days from today. Items without a parseable date stay in (we don't
// want to silently hide rows on bad data). today is inclusive.
function filterByHorizonDays<T>(items: T[], days: number, key: (t: T) => string): T[] {
if (!Number.isFinite(days) || days <= 0) return items;
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setDate(cutoff.getDate() + days);
return items.filter((t) => {
const raw = key(t);
if (!raw) return true;
// due_date is "YYYY-MM-DD"; start_at is RFC 3339. Both parseable
// by Date.
const d = new Date(raw.length === 10 ? raw + "T00:00:00" : raw);
if (isNaN(d.getTime())) return true;
return d.getTime() <= cutoff.getTime();
});
}
function toggleOnboardingHint(user: DashboardUser | null): void {
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
// already redirects users without a paliad.users row to /onboarding before
// the dashboard HTML is served. If the gate ever misses (e.g. DB lookup
// errored and we fell through), push the user to /onboarding here so they
// don't get stuck on a blank dashboard.
if (!user) {
window.location.href = "/onboarding";
return;
}
const onboarding = document.getElementById("dashboard-onboarding")!;
onboarding.style.display = "none";
}
function setCount(id: string, n: number): void {
const el = document.getElementById(id);
if (el) el.textContent = String(n);
}
function formatRelative(isoDate: string): string {
const due = new Date(isoDate + "T00:00:00");
if (isNaN(due.getTime())) return isoDate;
const today = new Date();
today.setHours(0, 0, 0, 0);
const diffDays = Math.round((due.getTime() - today.getTime()) / 86400000);
const lang = getLang();
if (diffDays < 0) {
const n = Math.abs(diffDays);
return lang === "de"
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
: (n === 1 ? "1 day ago" : `${n} days ago`);
}
if (diffDays === 0) return t("dashboard.when.today");
if (diffDays === 1) return t("dashboard.when.tomorrow");
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const locale = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleString(locale, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function escAttr(s: string): string {
return (s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function schedulePolling(): void {
// Refresh the payload every minute so open dashboards stay current when
// teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
// The inline agenda is refreshed on the same cadence to stay in sync
// with the deadlines/appointments rails above it.
window.setInterval(() => {
void loadDashboard();
void loadAgenda();
}, POLL_INTERVAL_MS);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initCollapsibleSections();
onLangChange(() => {
render();
syncCollapseAriaLabels();
syncEditToggleLabel();
if (editMode) rebuildEditChrome();
});
// Configurable layout (t-paliad-219). The Go shell handler splices
// the user's saved layout into __PALIAD_DASHBOARD_LAYOUT__. If it's
// missing (knowledge-platform-only deploy, hydration failure), the
// dashboard renders the factory order baked into dashboard.tsx; the
// client also kicks off a best-effort fetch so a slow-hydrating user
// still gets their saved layout on the next render pass.
const layoutInline = window.__PALIAD_DASHBOARD_LAYOUT__;
if (layoutInline) {
currentLayout = layoutInline;
} else if (layoutInline === undefined) {
void fetch("/api/me/dashboard-layout").then(async (r) => {
if (!r.ok) return;
currentLayout = (await r.json()) as DashboardLayoutSpec;
if (data) render();
}).catch(() => { /* silent — factory order is the fallback */ });
}
// Widget catalog — server-inlined by dashboard_shell.go so the
// picker / gear popover can render without a /api/dashboard-widget-
// catalog round-trip. Fall back to a fetch on hydration miss.
const catalogInline = window.__PALIAD_DASHBOARD_CATALOG__;
if (catalogInline && Array.isArray(catalogInline)) {
currentCatalog = catalogInline;
} else if (catalogInline === undefined) {
void fetch("/api/dashboard-widget-catalog").then(async (r) => {
if (!r.ok) return;
currentCatalog = (await r.json()) as WidgetCatalogEntry[];
}).catch(() => { /* silent — picker shows empty state */ });
}
// Edit-mode wiring (t-paliad-219 Slice B). Toggle starts off; chrome
// is built lazily on first activation so view-mode pays nothing.
initEditToggle();
initEditFooter();
// Inline agenda fetch is independent of the main dashboard payload.
// Kicked off in parallel so the agenda section paints as soon as the
// /api/agenda response lands instead of waiting on the dashboard
// payload poll.
void loadAgenda();
const inlined = window.__PALIAD_DASHBOARD__;
if (inlined !== undefined) {
// Server-side hydration path: the handler spliced the payload directly
// into the HTML. Render synchronously, then start polling.
if (inlined === null) {
document.getElementById("dashboard-unavailable")!.style.display = "block";
} else {
data = inlined;
render();
}
schedulePolling();
return;
}
// Fallback for dev or when the handler couldn't splice (no DB, etc.).
void loadDashboard().then(schedulePolling);
});