Sidebar:
- Paliadin lifted out of Übersicht to a top-level entry directly under
Home (owner-only reveal logic unchanged — same id reused).
- Agenda removed from sidebar; the standalone /agenda route stays for
direct-link compatibility but the dashboard hosts its content inline.
- Projekte moved into Übersicht; Fristen + Termine moved into a new
Ansichten group; the Arbeit group is gone.
- Werkzeuge / Wissen / Ressourcen collapsed into one Werkzeuge group
per m's brief order (calculators → reference → content).
- BottomNav agenda slot repointed to /events?type=deadline so the
overdue+today badge still has a sensible target on mobile.
Dashboard:
- Agenda renders inline as a new collapsible section between the
upcoming-rails grid and Letzte Aktivität, with a "Vollständige Agenda
öffnen →" link to the standalone page.
- Letzte Aktivität moved under Agenda per m's design call.
- Sections (summary, deadlines, appointments, agenda, activity) become
collapsible via a chevron toggle; state persists in
localStorage[paliad:dashboard:collapse:<section>]. Matters card stays
whole-card-tappable, so it's intentionally left non-collapsible.
- Inline agenda fetches /api/agenda directly with a 30-day window and
refreshes on the existing 60s dashboard poll.
Render primitives:
- New client/agenda-render.ts hosts renderAgendaTimeline + AgendaItem
type, shared by client/agenda.ts and client/dashboard.ts. Standalone
agenda.ts shrinks accordingly; behaviour is identical.
i18n:
- Added nav.group.ansichten + dashboard.agenda.* + dashboard.section.*
keys (DE/EN). Removed nav.group.{arbeit,wissen,ressourcen} (no other
callers; i18n-keys.ts auto-regenerated).
543 lines
19 KiB
TypeScript
543 lines
19 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
|
|
|
interface DashboardUser {
|
|
id: string;
|
|
email: string;
|
|
display_name: string;
|
|
office: string;
|
|
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[];
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__PALIAD_DASHBOARD__?: DashboardData | null;
|
|
}
|
|
}
|
|
|
|
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);
|
|
toggleOnboardingHint(data.user);
|
|
}
|
|
|
|
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")!;
|
|
|
|
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")!;
|
|
|
|
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")!;
|
|
|
|
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 empty = document.getElementById("dashboard-agenda-empty");
|
|
if (!timeline || !empty) return;
|
|
if (agendaItems === null) {
|
|
// Items haven't landed yet — keep the timeline blank but hide empty
|
|
// hint so we don't flash "nothing due" before the fetch resolves.
|
|
timeline.innerHTML = "";
|
|
timeline.style.display = "none";
|
|
empty.style.display = "none";
|
|
return;
|
|
}
|
|
if (!agendaItems.length) {
|
|
timeline.innerHTML = "";
|
|
timeline.style.display = "none";
|
|
empty.style.display = "block";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
timeline.style.display = "";
|
|
timeline.innerHTML = renderAgendaTimeline(agendaItems);
|
|
}
|
|
|
|
async function loadAgenda(): Promise<void> {
|
|
const from = toAgendaDate(startOfToday());
|
|
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 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 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();
|
|
});
|
|
|
|
// 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);
|
|
});
|