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.
2158 lines
80 KiB
TypeScript
2158 lines
80 KiB
TypeScript
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)} · ${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)} · ${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)} · ${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, "&").replace(/"/g, """);
|
||
}
|
||
|
||
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);
|
||
});
|