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 | 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 { 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 `
  • ${esc(d.title)} ${esc(d.project_reference)} · ${esc(d.project_title)}
    ${esc(formatRelative(d.due_date))}
  • `; }).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 ? `` : ``; 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 ? `${esc(a.project_reference)} · ${esc(a.project_title)}` : ""; return `
  • <${tag} href="${href}" class="dashboard-list-link">
    ${dot}${esc(a.title)} ${projectLine}
    ${esc(formatDateTime(a.start_at))}
  • `; }).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 `
  • ${esc(formatDateTime(e.timestamp))}

    ${esc(actor)} ${esc(shortAction)}

    ${esc(e.project_reference)}${detail ? ` ${esc(detail)}` : ""}

  • `; }).join(""); // Row-level click handler: clicking anywhere on the row navigates to the // same target as the inner .dashboard-activity-project link. Inner / //