feat(dashboard): t-paliad-219 Slice A4 — frontend widget dispatch + inbox-approvals
Wire the configurable dashboard end-to-end on the frontend side. Factory
render only (edit mode is Slice B).
dashboard.tsx:
- Add data-widget-key to every section that participates in the layout
(deadline-summary, matter-summary, upcoming-deadlines, upcoming-
appointments, inline-agenda, recent-activity, inbox-approvals).
- New inbox-approvals section markup with summary line, list, empty
state, and full-inbox link.
- Triple hydration placeholder: data + layout + catalog spliced as
separate window.__PALIAD_DASHBOARD_* globals.
dashboard_shell.go + dashboard.go:
- Three placeholder splice instead of one. splicePlaceholder() helper
consolidates the JS-assignment encoding.
- handleDashboardPage pre-fetches the user's saved layout via
dashboardLayout.GetOrSeed and inlines the WidgetCatalog (code-
resident — always inlined so the widget picker can boot on knowledge-
platform-only deploys too).
dashboard.ts client:
- New InboxSummary / InboxEntry / DashboardLayoutSpec / DashboardWidgetRef
types mirroring the Go shapes.
- settingsFor(key) reads per-widget settings (count, horizon_days) from
the active layout; defaults fall back to catalog values.
- Existing renderers (Deadlines, Appointments, Activity, Agenda) thread
count + horizon settings — backend now returns 60d / LIMIT 40 so the
client narrows per the user's widget config.
- New renderInbox() renders the inbox-approvals widget with summary
copy ("N offene Freigaben warten auf dich"), top-N entry list, and
the empty state.
- applyLayout() walks the saved spec and (a) hides widgets whose
layout entry is visible:false and (b) reorders visible widgets via
parent.appendChild within their existing parent — preserves the
.dashboard-columns 2-up grid for deadlines+appointments.
- filterByHorizonDays() filters list items by date relative to today.
- Boot wiring: read __PALIAD_DASHBOARD_LAYOUT__ at mount; if missing,
best-effort fetch /api/me/dashboard-layout and re-render once data
has landed. Factory order baked into dashboard.tsx is the fallback
so a hydration failure never breaks the dashboard.
i18n: 5 new keys per language for the inbox widget. 2528 → 2533.
go build + go vet + go test ./internal/... -short + bun run build all
clean. Triple placeholder verified present in dist/dashboard.html.
Pixel-identical factory render budget: every previously-visible widget
keeps its DOM markup, classes, IDs, and parent. New widget (inbox-
approvals) lands between agenda and activity per the factory layout
ordering in WidgetCatalog. Visible regression on the factory layout is
+1 section (inbox-approvals), expected per m's Q3 pick.
This commit is contained in:
@@ -65,14 +65,60 @@ interface DashboardData {
|
||||
upcoming_deadlines: UpcomingDeadline[];
|
||||
upcoming_appointments: UpcomingAppointment[];
|
||||
recent_activity: ActivityEntry[];
|
||||
inbox_summary?: InboxSummary;
|
||||
}
|
||||
|
||||
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).
|
||||
interface DashboardWidgetRef {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
settings?: { count?: number; horizon_days?: number };
|
||||
}
|
||||
interface DashboardLayoutSpec {
|
||||
v: number;
|
||||
widgets: DashboardWidgetRef[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_DASHBOARD__?: DashboardData | null;
|
||||
__PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
|
||||
__PALIAD_DASHBOARD_CATALOG__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
let currentLayout: DashboardLayoutSpec | null = null;
|
||||
|
||||
// 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): { count?: number; horizon_days?: number } {
|
||||
if (!currentLayout) return {};
|
||||
for (const w of currentLayout.widgets) {
|
||||
if (w.key === key) return w.settings ?? {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
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
|
||||
@@ -110,7 +156,13 @@ function render(): void {
|
||||
renderAppointments(data.upcoming_appointments);
|
||||
renderAgenda();
|
||||
renderActivity(data.recent_activity);
|
||||
renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
|
||||
toggleOnboardingHint(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 {
|
||||
@@ -162,6 +214,13 @@ function renderDeadlines(items: UpcomingDeadline[]): void {
|
||||
const list = document.getElementById("dashboard-deadlines-list")!;
|
||||
const empty = document.getElementById("dashboard-deadlines-empty")!;
|
||||
|
||||
// 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);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -191,6 +250,10 @@ function renderAppointments(items: UpcomingAppointment[]): void {
|
||||
const list = document.getElementById("dashboard-appointments-list")!;
|
||||
const empty = document.getElementById("dashboard-appointments-empty")!;
|
||||
|
||||
const s = settingsFor("upcoming-appointments");
|
||||
items = filterByHorizonDays(items, s.horizon_days ?? 30, (a) => a.start_at);
|
||||
items = items.slice(0, s.count ?? 10);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -226,6 +289,9 @@ 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);
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = "";
|
||||
list.style.display = "none";
|
||||
@@ -344,8 +410,10 @@ function renderAgenda(): void {
|
||||
}
|
||||
|
||||
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(), AGENDA_LOOKAHEAD_DAYS - 1));
|
||||
const to = toAgendaDate(addDays(startOfToday(), horizon - 1));
|
||||
try {
|
||||
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
|
||||
if (!resp.ok) {
|
||||
@@ -439,6 +507,125 @@ function syncCollapseAriaLabels(): void {
|
||||
});
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
// applyLayout walks the saved DashboardLayoutSpec and hides widgets whose
|
||||
// keys are `visible: false`, then reorders the visible ones to match the
|
||||
// layout's order. Widgets in the layout but missing from the DOM are
|
||||
// ignored (the catalog must define the markup for them — Slice A has
|
||||
// every catalog widget pre-rendered in dashboard.tsx). Widgets in the
|
||||
// DOM but missing from the layout (e.g. a deploy added markup ahead of a
|
||||
// migration) stay in their authored position so nothing disappears
|
||||
// silently.
|
||||
//
|
||||
// Reordering target: the visible widgets live in two parents — the
|
||||
// outer .container and the .dashboard-columns 2-up grid. We respect
|
||||
// that boundary: widgets inside .dashboard-columns are reordered within
|
||||
// it; widgets outside are reordered relative to each other inside
|
||||
// .container. This keeps the existing 2-up behaviour for the
|
||||
// deadlines+appointments pair without forcing a full container flatten.
|
||||
function applyLayout(): void {
|
||||
if (!currentLayout || !Array.isArray(currentLayout.widgets)) return;
|
||||
|
||||
// Discover widget elements once. data-widget-key set in dashboard.tsx.
|
||||
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);
|
||||
});
|
||||
|
||||
// Hide widgets whose layout entry says visible:false. Anything not in
|
||||
// the layout at all stays untouched.
|
||||
const seenInLayout = new Set<string>();
|
||||
for (const w of currentLayout.widgets) {
|
||||
seenInLayout.add(w.key);
|
||||
const el = byKey.get(w.key);
|
||||
if (!el) continue;
|
||||
el.style.display = w.visible ? "" : "none";
|
||||
}
|
||||
|
||||
// Reorder visible widgets inside each parent. We group widgets by their
|
||||
// current parent element so we don't move them out of .dashboard-columns
|
||||
// and lose the 2-up grid layout.
|
||||
const groups = new Map<HTMLElement, HTMLElement[]>();
|
||||
for (const w of currentLayout.widgets) {
|
||||
if (!w.visible) continue;
|
||||
const el = byKey.get(w.key);
|
||||
if (!el || !el.parentElement) continue;
|
||||
const arr = groups.get(el.parentElement) ?? [];
|
||||
arr.push(el);
|
||||
groups.set(el.parentElement, arr);
|
||||
}
|
||||
groups.forEach((widgets, parent) => {
|
||||
widgets.forEach((el) => parent.appendChild(el));
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -518,6 +705,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
syncCollapseAriaLabels();
|
||||
});
|
||||
|
||||
// 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 */ });
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -911,6 +911,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Keine F\u00e4lligkeiten in den n\u00e4chsten 30 Tagen.",
|
||||
"dashboard.agenda.full_link": "Vollst\u00e4ndige Agenda \u00f6ffnen \u2192",
|
||||
// Inbox-approvals widget (t-paliad-219).
|
||||
"dashboard.inbox.heading": "Offene Freigaben",
|
||||
"dashboard.inbox.empty": "Keine offenen Freigaben.",
|
||||
"dashboard.inbox.full_link": "Vollst\u00e4ndigen Posteingang \u00f6ffnen \u2192",
|
||||
"dashboard.inbox.entity.deadline": "Frist",
|
||||
"dashboard.inbox.entity.appointment": "Termin",
|
||||
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
|
||||
// are needed because the aria-label flips with the expanded state.
|
||||
"dashboard.section.collapse": "Abschnitt einklappen",
|
||||
@@ -3589,6 +3595,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Nothing due in the next 30 days.",
|
||||
"dashboard.agenda.full_link": "Open full agenda →",
|
||||
"dashboard.inbox.heading": "Open approvals",
|
||||
"dashboard.inbox.empty": "No open approvals.",
|
||||
"dashboard.inbox.full_link": "Open full inbox →",
|
||||
"dashboard.inbox.entity.deadline": "Deadline",
|
||||
"dashboard.inbox.entity.appointment": "Appointment",
|
||||
"dashboard.section.collapse": "Collapse section",
|
||||
"dashboard.section.expand": "Expand section",
|
||||
"dashboard.urgency.overdue": "Overdue",
|
||||
|
||||
@@ -5,12 +5,14 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
|
||||
// once in the output.
|
||||
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
|
||||
// request time by the Go handler (internal/handlers/dashboard_shell.go)
|
||||
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
|
||||
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
|
||||
// Keep each token intact and exactly once in the output. The latter two
|
||||
// power the per-user configurable layout (t-paliad-219).
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
@@ -23,12 +25,13 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
widgetKey: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
@@ -88,7 +91,7 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
@@ -116,7 +119,7 @@ export function renderDashboard(): string {
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<section className="dashboard-matters" data-widget-key="matter-summary">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
|
||||
@@ -145,14 +148,14 @@ export function renderDashboard(): string {
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
@@ -166,7 +169,7 @@ export function renderDashboard(): string {
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
@@ -178,9 +181,26 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
|
||||
list mirrors /inbox's "Approver" axis but capped at the
|
||||
widget's count setting. Renders the empty state when
|
||||
the user has no open approvals to review. */}
|
||||
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
|
||||
<div className="dashboard-inbox">
|
||||
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
|
||||
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
|
||||
Keine offenen Freigaben.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollständigen Posteingang öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
|
||||
@@ -927,6 +927,11 @@ export type I18nKey =
|
||||
| "dashboard.deadlines.empty"
|
||||
| "dashboard.deadlines.heading"
|
||||
| "dashboard.greeting.prefix"
|
||||
| "dashboard.inbox.empty"
|
||||
| "dashboard.inbox.entity.appointment"
|
||||
| "dashboard.inbox.entity.deadline"
|
||||
| "dashboard.inbox.full_link"
|
||||
| "dashboard.inbox.heading"
|
||||
| "dashboard.matters.active"
|
||||
| "dashboard.matters.archived"
|
||||
| "dashboard.matters.heading"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/dashboard — returns the DashboardData JSON for the logged-in user.
|
||||
@@ -24,21 +25,29 @@ func handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /dashboard — protected shell page. The client boots, reads the initial
|
||||
// payload inlined by the server into window.__PALIAD_DASHBOARD__, and renders
|
||||
// without a second round-trip (audit §2.3: no skeleton→fetch waterfall).
|
||||
// GET /dashboard — protected shell page. The client boots, reads three
|
||||
// initial payloads inlined by the server (data, layout, catalog), and
|
||||
// renders without a second round-trip (audit §2.3: no skeleton→fetch
|
||||
// waterfall). Each inline is best-effort: if any read fails the
|
||||
// corresponding blob is left null and the client falls back to fetch.
|
||||
func handleDashboardPage(w http.ResponseWriter, r *http.Request) {
|
||||
uid, hasUser := auth.UserIDFromContext(r.Context())
|
||||
var payload []byte
|
||||
var payload, layout []byte
|
||||
if hasUser && dbSvc != nil {
|
||||
// Best-effort server-render. If the DB read fails we still serve the
|
||||
// shell; the client will show the inline error state instead of the
|
||||
// zero-count cards.
|
||||
if data, err := dbSvc.dashboard.Get(r.Context(), uid); err == nil {
|
||||
payload = mustJSON(data)
|
||||
}
|
||||
if dbSvc.dashboardLayout != nil {
|
||||
if spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid); err == nil {
|
||||
layout = mustJSON(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
serveDashboardShell(w, r, payload)
|
||||
// Catalog is code-resident — always inline it so the widget picker
|
||||
// and dispatch logic can boot without an extra fetch even on
|
||||
// knowledge-platform-only deployments without DATABASE_URL.
|
||||
catalog := mustJSON(services.WidgetCatalog())
|
||||
serveDashboardShell(w, r, payload, layout, catalog)
|
||||
}
|
||||
|
||||
// handleRootPage is the public `/` route. Unauthenticated visitors get the
|
||||
|
||||
@@ -11,10 +11,15 @@ import (
|
||||
)
|
||||
|
||||
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
|
||||
// and contains the placeholder token below. On each request we splice in a
|
||||
// JSON blob as `window.__PALIAD_DASHBOARD__` so the client can paint the real
|
||||
// data on first frame — no skeleton + /api/dashboard waterfall.
|
||||
const dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
// and contains three placeholder tokens (data, layout, catalog). On each
|
||||
// request we splice in JSON blobs as window.__PALIAD_DASHBOARD__ /
|
||||
// __PALIAD_DASHBOARD_LAYOUT__ / __PALIAD_DASHBOARD_CATALOG__ so the client
|
||||
// can paint the real data on first frame — no skeleton + /api/* waterfall.
|
||||
const (
|
||||
dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
dashboardLayoutPlaceholder = "/*__PALIAD_DASHBOARD_LAYOUT__*/"
|
||||
dashboardCatalogPlaceholder = "/*__PALIAD_DASHBOARD_CATALOG__*/"
|
||||
)
|
||||
|
||||
var (
|
||||
dashboardShellOnce sync.Once
|
||||
@@ -38,28 +43,19 @@ func loadDashboardShell() ([]byte, error) {
|
||||
return dashboardShellBytes, dashboardShellErr
|
||||
}
|
||||
|
||||
// serveDashboardShell writes dist/dashboard.html with the JSON payload spliced
|
||||
// into the placeholder. A nil payload disables server-side hydration; the
|
||||
// client then falls back to fetching /api/dashboard on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
|
||||
// serveDashboardShell writes dist/dashboard.html with three JSON blobs
|
||||
// spliced in (data, layout, catalog). A nil payload disables server-side
|
||||
// hydration of that slot; the client falls back to fetching the
|
||||
// corresponding /api/* endpoint on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload, layout, catalog []byte) {
|
||||
shell, err := loadDashboardShell()
|
||||
if err != nil {
|
||||
http.Error(w, "dashboard shell unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body []byte
|
||||
if len(payload) > 0 {
|
||||
// JSON is wrapped so the script block is self-contained even when the
|
||||
// payload contains `</script>` sequences (defensive: our data is
|
||||
// server-owned, but future event.description fields could contain
|
||||
// arbitrary text).
|
||||
inline := append([]byte("window.__PALIAD_DASHBOARD__="), escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder), inline, 1)
|
||||
} else {
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder),
|
||||
[]byte("window.__PALIAD_DASHBOARD__=null;"), 1)
|
||||
}
|
||||
body := splicePlaceholder(shell, dashboardDataPlaceholder, "window.__PALIAD_DASHBOARD__=", payload)
|
||||
body = splicePlaceholder(body, dashboardLayoutPlaceholder, "window.__PALIAD_DASHBOARD_LAYOUT__=", layout)
|
||||
body = splicePlaceholder(body, dashboardCatalogPlaceholder, "window.__PALIAD_DASHBOARD_CATALOG__=", catalog)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -67,6 +63,22 @@ func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// splicePlaceholder replaces a single placeholder token with a JS
|
||||
// assignment of the given JSON payload to a window.X global. A nil
|
||||
// payload assigns `null` so the client can detect "no server-side
|
||||
// hydration" and fall back to fetch.
|
||||
func splicePlaceholder(shell []byte, placeholder, prefix string, payload []byte) []byte {
|
||||
var inline []byte
|
||||
if len(payload) > 0 {
|
||||
inline = append(inline, []byte(prefix)...)
|
||||
inline = append(inline, escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
} else {
|
||||
inline = append(inline, []byte(prefix+"null;")...)
|
||||
}
|
||||
return bytes.Replace(shell, []byte(placeholder), inline, 1)
|
||||
}
|
||||
|
||||
// escapeForScript makes a JSON blob safe to embed directly in an inline
|
||||
// <script>. JSON strings may contain `</script>` or U+2028/U+2029, both of
|
||||
// which terminate script blocks in some parsers.
|
||||
|
||||
Reference in New Issue
Block a user