Compare commits
8 Commits
mai/leibni
...
mai/darwin
| Author | SHA1 | Date | |
|---|---|---|---|
| aa435e5435 | |||
| 3966394a39 | |||
| 5dacc97a6b | |||
| 15bcba5d7c | |||
| 48f78a713b | |||
| a421bff856 | |||
| 0aa81139a3 | |||
| fbd087e0cd |
@@ -178,14 +178,23 @@ func main() {
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
// the dashboard, and DashboardService can render its other widgets
|
||||
// without approvals — so keeping this a setter keeps both
|
||||
// constructors simple).
|
||||
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
|
||||
// date editor) live in `./views/verfahrensablauf-core` and are shared
|
||||
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
|
||||
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
|
||||
// wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
priorityRendering,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -430,54 +431,21 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
applyPendingFocus();
|
||||
}
|
||||
|
||||
// openInlineDateEditor swaps the date span for a date input. On commit
|
||||
// (blur or Enter), the override is recorded and the timeline re-fetched.
|
||||
// On Escape, the editor closes without changing anything. An empty
|
||||
// commit clears the override (lets the user revert to the calculated
|
||||
// date or to the IsCourtSet placeholder).
|
||||
function openInlineDateEditor(span: HTMLElement) {
|
||||
const ruleCode = span.dataset.ruleCode!;
|
||||
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
const commit = (newValue: string) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||||
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||||
// clears it) then recompute so downstream rules re-anchor.
|
||||
function onDateEditCommit(ruleCode: string, newValue: string) {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
}
|
||||
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
|
||||
// openInlineDateEditor / wireDateEditClicks moved to
|
||||
// ./views/verfahrensablauf-core.
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -648,21 +616,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// rules re-anchor on the user's date. Delegated on the container so
|
||||
// it survives renderProcedureResults() innerHTML rewrites.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
timelineContainer.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
timelineContainer.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
}
|
||||
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||||
|
||||
// Reset button
|
||||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,11 +17,21 @@ import {
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -125,10 +135,14 @@ async function doCalc() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -180,8 +194,8 @@ function renderResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, showNotes });
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
@@ -229,7 +243,12 @@ function syncInfAmendEnabled() {
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
const nextType = btn.dataset.code || "";
|
||||
// Different proceeding tree → previously-set overrides reference
|
||||
// rule codes that don't exist in the new tree. Clear before the
|
||||
// next calc so the fresh proceeding starts unanchored.
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
@@ -312,6 +331,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Click-to-edit on timeline / column date cells — same delegated
|
||||
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||
// innerHTML rewrites because the listener lives on the container.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
|
||||
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
|
||||
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
|
||||
// date` on the date span. Pages then attach a delegated click handler that
|
||||
// resolves that selector to swap in an inline `<input type="date">`. If a
|
||||
// future refactor drops the attrs, /tools/verfahrensablauf and
|
||||
// /tools/fristenrechner both silently lose click-to-edit (no script error,
|
||||
// nothing happens on click). These tests pin the contract.
|
||||
//
|
||||
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
|
||||
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
|
||||
// in plain Node without jsdom).
|
||||
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "upc-rop-12",
|
||||
name: "Klageerwiderung",
|
||||
nameEN: "Statement of Defence",
|
||||
party: "defendant",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-15",
|
||||
originalDate: "2026-07-15",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
|
||||
expect(html).toContain('class="timeline-date frist-date-edit"');
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
expect(html).toContain('data-current-date="2026-07-15"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
|
||||
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
|
||||
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
|
||||
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
|
||||
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
|
||||
expect(html).toContain("timeline-court-set frist-date-edit");
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
});
|
||||
|
||||
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
|
||||
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
@@ -299,6 +299,87 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
${notesBlock}`;
|
||||
}
|
||||
|
||||
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||||
//
|
||||
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||||
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||||
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||||
// result container once, and the delegated click/keydown handlers swap a
|
||||
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||||
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||||
// newValue means "revert" (clear the anchor override and let the calculator
|
||||
// re-project). The actual recompute is the caller's job — they own the
|
||||
// anchor-overrides map + the calc dispatch.
|
||||
|
||||
export function openInlineDateEditor(
|
||||
span: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
const ruleCode = span.dataset.ruleCode || "";
|
||||
if (!ruleCode) return;
|
||||
const current = span.dataset.currentDate || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
let done = false;
|
||||
const cancel = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
const commit = (newValue: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
onCommit(ruleCode, newValue);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||||
// timeline result container so click-to-edit survives every innerHTML
|
||||
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||||
// container does nothing (the dataset flag short-circuits).
|
||||
export function wireDateEditClicks(
|
||||
container: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
if (container.dataset.dateEditWired === "1") return;
|
||||
container.dataset.dateEditWired = "1";
|
||||
container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
container.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 109_user_dashboard_layouts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;
|
||||
29
internal/db/migrations/109_user_dashboard_layouts.up.sql
Normal file
29
internal/db/migrations/109_user_dashboard_layouts.up.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- t-paliad-219 Slice A1: per-user dashboard layout.
|
||||
--
|
||||
-- Design: docs/design-dashboard-configurable-2026-05-20.md §5.1 (newton,
|
||||
-- m-locked 2026-05-20: single layout per user, Q2).
|
||||
--
|
||||
-- Stores one configurable dashboard layout per user as a single jsonb
|
||||
-- column. The layout is an ordered list of (widget_key, visible, settings)
|
||||
-- triples; see internal/services/dashboard_layout_spec.go DashboardLayoutSpec.
|
||||
--
|
||||
-- Single-row-per-user PK because m's Q2 pick is one layout per user (v1) —
|
||||
-- no named-layout switcher. Forward path to named layouts (drop the PK, add
|
||||
-- id+name+is_default columns) stays open if m later changes course.
|
||||
--
|
||||
-- RLS owner-only mirrors user_card_layouts / user_views — personal working
|
||||
-- state, not auditable infrastructure. global_admin gets no override.
|
||||
|
||||
CREATE TABLE paliad.user_dashboard_layouts (
|
||||
user_id uuid PRIMARY KEY REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
layout_json jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.user_dashboard_layouts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY user_dashboard_layouts_owner_all
|
||||
ON paliad.user_dashboard_layouts FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
@@ -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
|
||||
|
||||
109
internal/handlers/dashboard_layout.go
Normal file
109
internal/handlers/dashboard_layout.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for the per-user dashboard layout (t-paliad-219 Slice A2).
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §9.
|
||||
//
|
||||
// Four endpoints:
|
||||
// GET /api/me/dashboard-layout → read (auto-seeds factory default)
|
||||
// PUT /api/me/dashboard-layout → replace (validates against catalog)
|
||||
// POST /api/me/dashboard-layout/reset → overwrite with factory default
|
||||
// GET /api/dashboard-widget-catalog → catalog metadata for the picker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/me/dashboard-layout — returns the caller's layout, seeding the
|
||||
// factory default on first call. Always returns 200 with a valid
|
||||
// DashboardLayoutSpec.
|
||||
func handleGetDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// PUT /api/me/dashboard-layout — replaces the caller's layout. Body must
|
||||
// be a complete DashboardLayoutSpec; the service validates against the
|
||||
// catalog and 400s on a bad spec.
|
||||
func handlePutDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
var spec services.DashboardLayoutSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.dashboardLayout.Update(r.Context(), uid, spec)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// POST /api/me/dashboard-layout/reset — overwrites the caller's layout
|
||||
// with the factory default. The previous layout is discarded.
|
||||
func handleResetDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
spec, err := dbSvc.dashboardLayout.ResetToDefault(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// GET /api/dashboard-widget-catalog — returns the widget catalog. Auth-
|
||||
// gated only because the catalog includes user-facing copy; nothing
|
||||
// security-sensitive is exposed. The handler is DB-independent (the
|
||||
// catalog is code-resident) so the requireDB gate is intentionally
|
||||
// skipped — knowledge-platform-only deployments can still surface the
|
||||
// catalog and we never want this endpoint to 503.
|
||||
func handleGetWidgetCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, services.WidgetCatalog())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -84,9 +84,10 @@ type Services struct {
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
CardLayout *services.CardLayoutService
|
||||
DashboardLayout *services.DashboardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// Submission generator (t-paliad-215) — Klageerwiderung &
|
||||
// friends. Three coordinated services: registry fetches templates
|
||||
@@ -157,9 +158,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
cardLayout: svc.CardLayout,
|
||||
dashboardLayout: svc.DashboardLayout,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +314,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
|
||||
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
|
||||
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
|
||||
// t-paliad-219 — per-user configurable dashboard layout.
|
||||
protected.HandleFunc("GET /api/me/dashboard-layout", handleGetDashboardLayout)
|
||||
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
|
||||
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
|
||||
@@ -52,6 +52,7 @@ type dbServices struct {
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
}
|
||||
|
||||
157
internal/services/dashboard_layout_service.go
Normal file
157
internal/services/dashboard_layout_service.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package services
|
||||
|
||||
// DashboardLayoutService is the CRUD layer for paliad.user_dashboard_layouts —
|
||||
// per-user configurable dashboard layout for /dashboard.
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.4.
|
||||
//
|
||||
// Visibility: every read and write is scoped to the calling user via the
|
||||
// RLS policy `user_dashboard_layouts_owner_all` on auth.uid() = user_id.
|
||||
// The service also AND-joins user_id in SQL for defense-in-depth.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// DashboardLayoutService manages paliad.user_dashboard_layouts.
|
||||
type DashboardLayoutService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewDashboardLayoutService wires the service.
|
||||
func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
|
||||
return &DashboardLayoutService{db: db}
|
||||
}
|
||||
|
||||
// GetOrSeed returns the caller's saved layout. On first call for a user
|
||||
// (no row), it inserts and returns the factory default. The seed is
|
||||
// idempotent — concurrent first-loads converge to the same row via the
|
||||
// ON CONFLICT DO NOTHING clause.
|
||||
//
|
||||
// The returned spec has SanitizeForRead applied; if any entries were
|
||||
// dropped (catalog shrank) the cleaned spec is also persisted back so the
|
||||
// next write doesn't trip on stale entries.
|
||||
func (s *DashboardLayoutService) GetOrSeed(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
||||
spec, found, err := s.fetch(ctx, userID)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if !found {
|
||||
return s.seedFactoryDefault(ctx, userID)
|
||||
}
|
||||
if spec.SanitizeForRead() {
|
||||
// Best-effort cleanup; on failure we still return the in-memory
|
||||
// sanitized spec — the user sees a clean dashboard either way.
|
||||
_ = s.upsert(ctx, userID, spec)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Update validates the spec and UPSERTs it. Returns the persisted spec
|
||||
// (round-tripped through the DB to confirm storage).
|
||||
func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) (DashboardLayoutSpec, error) {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if err := s.upsert(ctx, userID, spec); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
out, found, err := s.fetch(ctx, userID)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if !found {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("dashboard layout vanished after upsert for user %s", userID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResetToDefault overwrites the user's layout with the factory default.
|
||||
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
||||
def := FactoryDefaultLayout()
|
||||
if err := s.upsert(ctx, userID, def); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// fetch returns (spec, found, err). found=false means the user has no row
|
||||
// yet — the seed path takes over.
|
||||
func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, bool, error) {
|
||||
var raw json.RawMessage
|
||||
err := s.db.GetContext(ctx, &raw, `
|
||||
SELECT layout_json
|
||||
FROM paliad.user_dashboard_layouts
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DashboardLayoutSpec{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, false, fmt.Errorf("fetch dashboard layout: %w", err)
|
||||
}
|
||||
var spec DashboardLayoutSpec
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
// Stored row is unparseable — treat as a missing row, the seed
|
||||
// path will overwrite it. Log via the returned error wrapper.
|
||||
return DashboardLayoutSpec{}, false, fmt.Errorf("dashboard layout JSON decode for user %s: %w", userID, err)
|
||||
}
|
||||
return spec, true, nil
|
||||
}
|
||||
|
||||
// seedFactoryDefault inserts the factory layout for a brand-new user.
|
||||
// ON CONFLICT DO NOTHING handles the race where two concurrent first
|
||||
// loads both miss the SELECT and both try to insert.
|
||||
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
|
||||
def := FactoryDefaultLayout()
|
||||
bytes, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
`, userID, json.RawMessage(bytes)); err != nil {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout insert: %w", err)
|
||||
}
|
||||
// Re-fetch in case ON CONFLICT DO NOTHING let another writer's row win;
|
||||
// either way the user now has a row.
|
||||
out, found, err := s.fetch(ctx, userID)
|
||||
if err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
if !found {
|
||||
// Extremely unlikely — would mean the row vanished between
|
||||
// INSERT and SELECT. Return the factory default in-memory.
|
||||
return def, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// upsert overwrites the layout. updated_at gets bumped on conflict so
|
||||
// callers can observe write recency.
|
||||
func (s *DashboardLayoutService) upsert(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) error {
|
||||
bytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard layout marshal: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET layout_json = EXCLUDED.layout_json,
|
||||
updated_at = now()
|
||||
`, userID, json.RawMessage(bytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard layout upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
181
internal/services/dashboard_layout_service_test.go
Normal file
181
internal/services/dashboard_layout_service_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for DashboardLayoutService. Skipped when TEST_DATABASE_URL
|
||||
// is unset.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
type dashboardLayoutTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
svc *DashboardLayoutService
|
||||
userID uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupDashboardLayoutTest(t *testing.T) *dashboardLayoutTestEnv {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Logf("skip auth.users seed: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
||||
VALUES ($1, $1::text || '@test.local', 'Dashboard Layout Test', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
c := context.Background()
|
||||
pool.ExecContext(c, `DELETE FROM paliad.user_dashboard_layouts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &dashboardLayoutTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
svc: NewDashboardLayoutService(pool),
|
||||
userID: userID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_GetOrSeedAutoSeeds(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
spec, err := env.svc.GetOrSeed(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrSeed: %v", err)
|
||||
}
|
||||
if spec.Version != LayoutSpecVersion {
|
||||
t.Errorf("seeded version=%d; want %d", spec.Version, LayoutSpecVersion)
|
||||
}
|
||||
if len(spec.Widgets) != len(KnownWidgetKeys) {
|
||||
t.Errorf("seeded widget count=%d; want %d", len(spec.Widgets), len(KnownWidgetKeys))
|
||||
}
|
||||
|
||||
// Second call returns the same row, not a second seed.
|
||||
spec2, err := env.svc.GetOrSeed(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrSeed second: %v", err)
|
||||
}
|
||||
if len(spec2.Widgets) != len(spec.Widgets) {
|
||||
t.Errorf("second call widget count drifted: %d vs %d", len(spec2.Widgets), len(spec.Widgets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_UpdateRoundTrips(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed first so the row exists.
|
||||
if _, err := env.svc.GetOrSeed(ctx, env.userID); err != nil {
|
||||
t.Fatalf("GetOrSeed: %v", err)
|
||||
}
|
||||
|
||||
// Custom layout: hide matter-summary, reorder.
|
||||
custom := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
|
||||
{Key: WidgetMatterSummary, Visible: false},
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
},
|
||||
}
|
||||
out, err := env.svc.Update(ctx, env.userID, custom)
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if len(out.Widgets) != 3 {
|
||||
t.Fatalf("Update returned %d widgets; want 3", len(out.Widgets))
|
||||
}
|
||||
if out.Widgets[0].Key != WidgetUpcomingDeadlines {
|
||||
t.Errorf("Update returned widgets[0]=%q; want %q", out.Widgets[0].Key, WidgetUpcomingDeadlines)
|
||||
}
|
||||
if out.Widgets[1].Visible {
|
||||
t.Errorf("Update returned widgets[1].Visible=true; want false")
|
||||
}
|
||||
|
||||
// Re-read confirms persistence.
|
||||
got, err := env.svc.GetOrSeed(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrSeed after update: %v", err)
|
||||
}
|
||||
if len(got.Widgets) != 3 {
|
||||
t.Errorf("GetOrSeed after update: %d widgets; want 3", len(got.Widgets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_UpdateRejectsInvalid(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
bad := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: "fake-widget-key", Visible: true},
|
||||
},
|
||||
}
|
||||
if _, err := env.svc.Update(ctx, env.userID, bad); err == nil {
|
||||
t.Fatalf("Update accepted invalid layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutService_ResetToDefault(t *testing.T) {
|
||||
env := setupDashboardLayoutTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Custom layout first.
|
||||
custom := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
},
|
||||
}
|
||||
if _, err := env.svc.Update(ctx, env.userID, custom); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
// Reset.
|
||||
reset, err := env.svc.ResetToDefault(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetToDefault: %v", err)
|
||||
}
|
||||
if len(reset.Widgets) != len(KnownWidgetKeys) {
|
||||
t.Errorf("reset widget count=%d; want %d", len(reset.Widgets), len(KnownWidgetKeys))
|
||||
}
|
||||
}
|
||||
176
internal/services/dashboard_layout_spec.go
Normal file
176
internal/services/dashboard_layout_spec.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
// DashboardLayoutSpec — JSON shape for paliad.user_dashboard_layouts.layout_json.
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.2.
|
||||
//
|
||||
// Validation surface:
|
||||
// - version must be 1 (v0 / unknown versions seed the factory default at
|
||||
// read time; the validator only ever sees writes from a current client).
|
||||
// - widgets is at most 32 entries (sanity cap; catalog can grow but a
|
||||
// single user's layout shouldn't).
|
||||
// - each widget.key must be in KnownWidgetKeys on WRITE.
|
||||
// - no duplicate keys.
|
||||
// - each widget.settings (if present) is validated against its catalog
|
||||
// entry's WidgetSettingsSchema.
|
||||
//
|
||||
// On READ, unknown keys are dropped silently — see SanitizeForRead.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// LayoutSpecVersion is the only supported version for v1.
|
||||
const LayoutSpecVersion = 1
|
||||
|
||||
// LayoutWidgetCap is the sanity cap on widgets per layout. The v1 catalog
|
||||
// has 7 entries; 32 leaves room for catalog growth without unbounded JSON
|
||||
// blobs.
|
||||
const LayoutWidgetCap = 32
|
||||
|
||||
// DashboardWidgetRef is a single widget entry in the ordered widgets[] array.
|
||||
// Visible=false entries are kept in the array so the picker can show them as
|
||||
// "hidden" and re-adding restores their position.
|
||||
type DashboardWidgetRef struct {
|
||||
Key WidgetKey `json:"key"`
|
||||
Visible bool `json:"visible"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardLayoutSpec is the persisted layout shape.
|
||||
type DashboardLayoutSpec struct {
|
||||
Version int `json:"v"`
|
||||
Widgets []DashboardWidgetRef `json:"widgets"`
|
||||
}
|
||||
|
||||
// FactoryDefaultLayout returns the Slice A1 baseline layout — every
|
||||
// widget in KnownWidgetKeys, visible, in canonical order, with per-widget
|
||||
// default settings drawn from the catalog. A user with no row sees this
|
||||
// on first load and is byte-identical to today's dashboard plus the new
|
||||
// inbox-approvals widget.
|
||||
func FactoryDefaultLayout() DashboardLayoutSpec {
|
||||
catalog := WidgetCatalog()
|
||||
byKey := make(map[WidgetKey]WidgetDef, len(catalog))
|
||||
for _, def := range catalog {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
widgets := make([]DashboardWidgetRef, 0, len(KnownWidgetKeys))
|
||||
for _, k := range KnownWidgetKeys {
|
||||
def, ok := byKey[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref := DashboardWidgetRef{Key: k, Visible: def.DefaultVisible}
|
||||
if settings := defaultSettingsJSON(def); settings != nil {
|
||||
ref.Settings = settings
|
||||
}
|
||||
widgets = append(widgets, ref)
|
||||
}
|
||||
|
||||
return DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: widgets,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSettingsJSON encodes the per-widget defaults declared on the
|
||||
// catalog entry. Returns nil when the widget has no settings.
|
||||
func defaultSettingsJSON(def WidgetDef) json.RawMessage {
|
||||
if def.DefaultCount == nil && def.DefaultHorizon == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]int{}
|
||||
if def.DefaultCount != nil {
|
||||
out["count"] = *def.DefaultCount
|
||||
}
|
||||
if def.DefaultHorizon != nil {
|
||||
out["horizon_days"] = *def.DefaultHorizon
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Validate enforces the structural invariants on write. Returns
|
||||
// ErrInvalidInput wrapped with a precise message on the first violation.
|
||||
func (s DashboardLayoutSpec) Validate() error {
|
||||
if s.Version != LayoutSpecVersion {
|
||||
return fmt.Errorf("%w: layout version %d not supported (want %d)",
|
||||
ErrInvalidInput, s.Version, LayoutSpecVersion)
|
||||
}
|
||||
if len(s.Widgets) > LayoutWidgetCap {
|
||||
return fmt.Errorf("%w: layout has %d widgets (cap %d)",
|
||||
ErrInvalidInput, len(s.Widgets), LayoutWidgetCap)
|
||||
}
|
||||
|
||||
seen := make(map[WidgetKey]bool, len(s.Widgets))
|
||||
for i, w := range s.Widgets {
|
||||
if !slices.Contains(KnownWidgetKeys, w.Key) {
|
||||
return fmt.Errorf("%w: widgets[%d].key %q is not a known widget",
|
||||
ErrInvalidInput, i, w.Key)
|
||||
}
|
||||
if seen[w.Key] {
|
||||
return fmt.Errorf("%w: widgets has duplicate key %q",
|
||||
ErrInvalidInput, w.Key)
|
||||
}
|
||||
seen[w.Key] = true
|
||||
|
||||
def, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
// Defense in depth — KnownWidgetKeys was checked above.
|
||||
return fmt.Errorf("%w: widgets[%d].key %q has no catalog entry",
|
||||
ErrInvalidInput, i, w.Key)
|
||||
}
|
||||
if err := def.Settings.Validate(w.Settings); err != nil {
|
||||
return fmt.Errorf("widgets[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
|
||||
// keys are not in the catalog (catalog has shrunk) and bump the version to
|
||||
// the current one if missing. Settings on surviving entries pass through
|
||||
// unchanged — invalid settings on read are not worth aborting over and the
|
||||
// next write will reject them anyway.
|
||||
//
|
||||
// Returns true if anything was changed; callers can use that to decide
|
||||
// whether to PUT the cleaned spec back.
|
||||
func (s *DashboardLayoutSpec) SanitizeForRead() bool {
|
||||
changed := false
|
||||
if s.Version != LayoutSpecVersion {
|
||||
s.Version = LayoutSpecVersion
|
||||
changed = true
|
||||
}
|
||||
if len(s.Widgets) == 0 {
|
||||
return changed
|
||||
}
|
||||
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
|
||||
for _, w := range s.Widgets {
|
||||
if _, ok := LookupWidgetDef(w.Key); !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
s.Widgets = out
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
|
||||
// HTTP handler on incoming request bodies.
|
||||
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
|
||||
var s DashboardLayoutSpec
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return DashboardLayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
return DashboardLayoutSpec{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
241
internal/services/dashboard_layout_spec_test.go
Normal file
241
internal/services/dashboard_layout_spec_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for DashboardLayoutSpec + WidgetCatalog.
|
||||
// No DB; safe to run in any environment.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
if def.Version != LayoutSpecVersion {
|
||||
t.Errorf("FactoryDefaultLayout version=%d; want %d", def.Version, LayoutSpecVersion)
|
||||
}
|
||||
if len(def.Widgets) != len(KnownWidgetKeys) {
|
||||
t.Fatalf("FactoryDefaultLayout has %d widgets; want %d", len(def.Widgets), len(KnownWidgetKeys))
|
||||
}
|
||||
for i, k := range KnownWidgetKeys {
|
||||
if def.Widgets[i].Key != k {
|
||||
t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
|
||||
}
|
||||
if !def.Widgets[i].Visible {
|
||||
t.Errorf("widgets[%d].Visible = false; factory default should be all-visible", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryDefaultLayout_SettingsDefaultsPresent(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
for _, w := range def.Widgets {
|
||||
catalogDef, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
t.Errorf("factory widget %q is not in catalog", w.Key)
|
||||
continue
|
||||
}
|
||||
hasDefaults := catalogDef.DefaultCount != nil || catalogDef.DefaultHorizon != nil
|
||||
if hasDefaults && len(w.Settings) == 0 {
|
||||
t.Errorf("widget %q has catalog defaults but factory layout has empty settings", w.Key)
|
||||
}
|
||||
if !hasDefaults && len(w.Settings) > 0 {
|
||||
t.Errorf("widget %q has no catalog defaults but factory layout has settings %s", w.Key, string(w.Settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryDefaultLayout_PassesValidation(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
if err := def.Validate(); err != nil {
|
||||
t.Fatalf("factory default failed Validate(): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_WrongVersion(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 99, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "version") {
|
||||
t.Errorf("error %q should mention 'version'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_TooManyWidgets(t *testing.T) {
|
||||
widgets := make([]DashboardWidgetRef, LayoutWidgetCap+1)
|
||||
for i := range widgets {
|
||||
widgets[i] = DashboardWidgetRef{Key: WidgetDeadlineSummary, Visible: true}
|
||||
}
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: widgets}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_UnknownKey(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: "not-a-real-widget", Visible: true},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
{Key: WidgetDeadlineSummary, Visible: false},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("error %q should mention 'duplicate'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
|
||||
// count not in CountOptions for upcoming-deadlines (legal: 1,3,5,10,20)
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_AcceptsValidSettings(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
|
||||
{Key: WidgetInlineAgenda, Visible: true, Settings: json.RawMessage(`{"horizon_days": 60}`)},
|
||||
{Key: WidgetRecentActivity, Visible: false},
|
||||
}}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("Validate returned %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_Validate_SettingsOnNoSettingsWidget(t *testing.T) {
|
||||
// deadline-summary has no Settings schema.
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"count": 5}`)},
|
||||
}}
|
||||
err := s.Validate()
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetDeadlineSummary, Visible: true},
|
||||
{Key: "deprecated-widget", Visible: true},
|
||||
{Key: WidgetInlineAgenda, Visible: true},
|
||||
}}
|
||||
changed := s.SanitizeForRead()
|
||||
if !changed {
|
||||
t.Errorf("SanitizeForRead returned false; expected true (one entry dropped)")
|
||||
}
|
||||
if len(s.Widgets) != 2 {
|
||||
t.Errorf("after sanitize: %d widgets; want 2", len(s.Widgets))
|
||||
}
|
||||
if s.Widgets[0].Key != WidgetDeadlineSummary || s.Widgets[1].Key != WidgetInlineAgenda {
|
||||
t.Errorf("after sanitize: keys = %v %v; want %v %v",
|
||||
s.Widgets[0].Key, s.Widgets[1].Key, WidgetDeadlineSummary, WidgetInlineAgenda)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
|
||||
s := FactoryDefaultLayout()
|
||||
if s.SanitizeForRead() {
|
||||
t.Errorf("SanitizeForRead on factory default returned true; want false (already clean)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_BumpsVersion(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: 0, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
|
||||
if !s.SanitizeForRead() {
|
||||
t.Errorf("SanitizeForRead returned false; expected version bump")
|
||||
}
|
||||
if s.Version != LayoutSpecVersion {
|
||||
t.Errorf("after sanitize: Version=%d; want %d", s.Version, LayoutSpecVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDashboardLayoutSpec_RoundTrip(t *testing.T) {
|
||||
def := FactoryDefaultLayout()
|
||||
bytes, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
parsed, err := ParseDashboardLayoutSpec(bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if parsed.Version != def.Version {
|
||||
t.Errorf("version mismatch: %d vs %d", parsed.Version, def.Version)
|
||||
}
|
||||
if len(parsed.Widgets) != len(def.Widgets) {
|
||||
t.Errorf("widget count mismatch: %d vs %d", len(parsed.Widgets), len(def.Widgets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDashboardLayoutSpec_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseDashboardLayoutSpec([]byte(`{not-json}`))
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("ParseDashboardLayoutSpec returned %v; want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
|
||||
for _, k := range KnownWidgetKeys {
|
||||
def, ok := LookupWidgetDef(k)
|
||||
if !ok {
|
||||
t.Errorf("KnownWidgetKeys entry %q has no WidgetDef", k)
|
||||
continue
|
||||
}
|
||||
if def.TitleDE == "" || def.TitleEN == "" {
|
||||
t.Errorf("widget %q missing title (de=%q en=%q)", k, def.TitleDE, def.TitleEN)
|
||||
}
|
||||
if def.DescriptionDE == "" || def.DescriptionEN == "" {
|
||||
t.Errorf("widget %q missing description", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
|
||||
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
|
||||
for _, k := range KnownWidgetKeys {
|
||||
known[k] = true
|
||||
}
|
||||
for _, def := range WidgetCatalog() {
|
||||
if !known[def.Key] {
|
||||
// Orphans are allowed (forward-compat: pinned-projects const
|
||||
// exists in widget_catalog.go before its widget module ships).
|
||||
// But verify the catalog entry is internally coherent.
|
||||
if def.TitleDE == "" || def.TitleEN == "" {
|
||||
t.Errorf("orphan catalog entry %q must still have titles", def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetSettingsSchema_NilRejectsNonEmpty(t *testing.T) {
|
||||
var sch *WidgetSettingsSchema
|
||||
if err := sch.Validate(json.RawMessage(`{"count": 5}`)); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("nil schema accepted settings; got %v", err)
|
||||
}
|
||||
if err := sch.Validate(nil); err != nil {
|
||||
t.Errorf("nil schema rejected empty settings: %v", err)
|
||||
}
|
||||
if err := sch.Validate(json.RawMessage(`null`)); err != nil {
|
||||
t.Errorf("nil schema rejected 'null' settings: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,24 @@ import (
|
||||
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
|
||||
// the Dashboard page.
|
||||
type DashboardService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
|
||||
return &DashboardService{db: db, users: users}
|
||||
}
|
||||
|
||||
// SetApprovalService wires the inbox-approvals widget data source. Called
|
||||
// post-construction so that DashboardService and ApprovalService can be
|
||||
// stitched together at boot without a circular constructor dependency.
|
||||
// Safe to leave nil — InboxSummary will then carry pending_count=0 and an
|
||||
// empty entries list, and the widget renders its empty state.
|
||||
func (s *DashboardService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// DashboardData is the full payload returned to the frontend.
|
||||
type DashboardData struct {
|
||||
User *DashboardUser `json:"user"`
|
||||
@@ -38,8 +48,42 @@ type DashboardData struct {
|
||||
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||
RecentActivity []ActivityEntry `json:"recent_activity"`
|
||||
InboxSummary InboxSummary `json:"inbox_summary"`
|
||||
}
|
||||
|
||||
// InboxSummary feeds the inbox-approvals widget on the configurable
|
||||
// dashboard (t-paliad-219). PendingCount is the precise number of
|
||||
// approval requests that await this user's approval; Top is a small
|
||||
// preview list (up to InboxTopCap entries) ordered oldest-pending-first
|
||||
// so the most urgent appears first.
|
||||
//
|
||||
// When the ApprovalService dependency is unwired (knowledge-platform-only
|
||||
// deployments, tests), PendingCount=0 and Top=[] so the widget renders
|
||||
// its empty state. The data path is read-only — no writes go through
|
||||
// the dashboard payload.
|
||||
type InboxSummary struct {
|
||||
PendingCount int `json:"pending_count"`
|
||||
Top []InboxEntry `json:"top"`
|
||||
}
|
||||
|
||||
// InboxEntry is a single row in InboxSummary.Top — the minimum needed
|
||||
// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z").
|
||||
type InboxEntry struct {
|
||||
RequestID uuid.UUID `json:"id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityTitle *string `json:"entity_title,omitempty"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProjectTitle string `json:"project_title"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
RequesterID uuid.UUID `json:"requester_id"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
}
|
||||
|
||||
// InboxTopCap caps the preview list. The widget's count setting tops out
|
||||
// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let
|
||||
// the client trim further per the user's setting.
|
||||
const InboxTopCap = 10
|
||||
|
||||
type DashboardUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -146,7 +190,12 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||
// t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d
|
||||
// so the per-widget horizon dropdown (7/14/30/60) can filter client-
|
||||
// side without re-querying. LIMIT bumps from 10 to 40 for the same
|
||||
// reason — the widget's count setting tops out at 20 plus headroom
|
||||
// for the agenda widget which can read from the same payload.
|
||||
endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02")
|
||||
bounds := computeDeadlineBucketBounds(now.UTC())
|
||||
|
||||
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
|
||||
@@ -161,6 +210,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
|
||||
if err := s.loadRecentActivity(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadInboxSummary(ctx, data, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
annotateUrgency(data.UpcomingDeadlines, now)
|
||||
return data, nil
|
||||
@@ -261,7 +313,7 @@ SELECT f.id,
|
||||
AND f.due_date <= $3::date
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
ORDER BY f.due_date ASC
|
||||
LIMIT 10`
|
||||
LIMIT 40`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
|
||||
user.ID, today, endOfWeek); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||
@@ -269,6 +321,45 @@ SELECT f.id,
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadInboxSummary populates DashboardData.InboxSummary — the open-
|
||||
// approval count + top InboxTopCap entries for the inbox-approvals
|
||||
// widget (t-paliad-219). When ApprovalService is unwired (knowledge-
|
||||
// platform-only deployments, tests), the function is a no-op and the
|
||||
// widget renders its empty state.
|
||||
func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error {
|
||||
data.InboxSummary = InboxSummary{Top: []InboxEntry{}}
|
||||
if s.approvals == nil {
|
||||
return nil
|
||||
}
|
||||
cnt, err := s.approvals.PendingCountForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard inbox count: %w", err)
|
||||
}
|
||||
data.InboxSummary.PendingCount = cnt
|
||||
if cnt == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dashboard inbox top: %w", err)
|
||||
}
|
||||
top := make([]InboxEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
top = append(top, InboxEntry{
|
||||
RequestID: r.ID,
|
||||
EntityType: r.EntityType,
|
||||
EntityTitle: r.EntityTitle,
|
||||
ProjectID: r.ProjectID,
|
||||
ProjectTitle: r.ProjectTitle,
|
||||
RequestedAt: r.RequestedAt,
|
||||
RequesterID: r.RequestedBy,
|
||||
RequesterName: r.RequesterName,
|
||||
})
|
||||
}
|
||||
data.InboxSummary.Top = top
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
|
||||
query := `
|
||||
SELECT t.id,
|
||||
@@ -282,13 +373,13 @@ SELECT t.id,
|
||||
FROM paliad.appointments t
|
||||
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE t.start_at >= $2
|
||||
AND t.start_at < ($2 + interval '7 days')
|
||||
AND t.start_at < ($2 + interval '60 days')
|
||||
AND (
|
||||
(t.project_id IS NULL AND t.created_by = $1)
|
||||
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
|
||||
)
|
||||
ORDER BY t.start_at ASC
|
||||
LIMIT 10`
|
||||
LIMIT 40`
|
||||
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
||||
user.ID, now); err != nil {
|
||||
return fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||
|
||||
51
internal/services/dashboard_service_test.go
Normal file
51
internal/services/dashboard_service_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for DashboardService extensions in Slice A3.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestDashboardService_InboxSummary_NilApprovalsIsNoop(t *testing.T) {
|
||||
s := &DashboardService{} // approvals nil
|
||||
data := &DashboardData{}
|
||||
user := &models.User{ID: uuid.New()}
|
||||
if err := s.loadInboxSummary(context.Background(), data, user); err != nil {
|
||||
t.Fatalf("loadInboxSummary with nil approvals returned %v; want nil", err)
|
||||
}
|
||||
if data.InboxSummary.PendingCount != 0 {
|
||||
t.Errorf("PendingCount=%d; want 0", data.InboxSummary.PendingCount)
|
||||
}
|
||||
if data.InboxSummary.Top == nil {
|
||||
t.Errorf("Top is nil; want empty slice")
|
||||
}
|
||||
if len(data.InboxSummary.Top) != 0 {
|
||||
t.Errorf("Top has %d entries; want 0", len(data.InboxSummary.Top))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardService_SetApprovalService_WiringWorks(t *testing.T) {
|
||||
s := &DashboardService{}
|
||||
if s.approvals != nil {
|
||||
t.Fatalf("freshly-constructed DashboardService has non-nil approvals")
|
||||
}
|
||||
a := &ApprovalService{} // empty shell; we only check the pointer wiring
|
||||
s.SetApprovalService(a)
|
||||
if s.approvals != a {
|
||||
t.Errorf("SetApprovalService did not wire the pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxTopCap_NonZero(t *testing.T) {
|
||||
// Sanity guard: if someone zeros this const, the inbox-approvals
|
||||
// widget falls back to an empty top-N silently. Pin it ≥ the
|
||||
// largest catalog count option for the inbox widget (10).
|
||||
if InboxTopCap < 10 {
|
||||
t.Errorf("InboxTopCap=%d; must be ≥ 10 to satisfy widget catalog max count", InboxTopCap)
|
||||
}
|
||||
}
|
||||
219
internal/services/widget_catalog.go
Normal file
219
internal/services/widget_catalog.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
// Widget catalog for the configurable dashboard (t-paliad-219).
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §4 (catalog) and
|
||||
// §18 Note B (settings schema).
|
||||
//
|
||||
// The catalog is the source of truth for which widgets a user can pick.
|
||||
// Adding a new widget = add a WidgetKey const + append a WidgetDef in
|
||||
// WidgetCatalog. Frontend has its own mirror in
|
||||
// frontend/src/client/widgets/registry.ts; the two must stay in sync.
|
||||
//
|
||||
// Versioning rule (design §10): unknown keys in a user's saved layout are
|
||||
// dropped silently at read time; write paths validate against the catalog.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// WidgetKey is the catalog identifier for a single widget.
|
||||
type WidgetKey string
|
||||
|
||||
const (
|
||||
WidgetDeadlineSummary WidgetKey = "deadline-summary"
|
||||
WidgetMatterSummary WidgetKey = "matter-summary"
|
||||
WidgetUpcomingDeadlines WidgetKey = "upcoming-deadlines"
|
||||
WidgetUpcomingAppointments WidgetKey = "upcoming-appointments"
|
||||
WidgetInlineAgenda WidgetKey = "inline-agenda"
|
||||
WidgetRecentActivity WidgetKey = "recent-activity"
|
||||
WidgetInboxApprovals WidgetKey = "inbox-approvals"
|
||||
WidgetPinnedProjects WidgetKey = "pinned-projects"
|
||||
)
|
||||
|
||||
// KnownWidgetKeys is the canonical order used when seeding the factory
|
||||
// default layout. New entries land at the bottom by default.
|
||||
var KnownWidgetKeys = []WidgetKey{
|
||||
WidgetDeadlineSummary,
|
||||
WidgetMatterSummary,
|
||||
WidgetUpcomingDeadlines,
|
||||
WidgetUpcomingAppointments,
|
||||
WidgetInlineAgenda,
|
||||
WidgetRecentActivity,
|
||||
WidgetInboxApprovals,
|
||||
// WidgetPinnedProjects ships in Slice C (catalog expansion) — not in
|
||||
// the Slice A1 baseline. Keep the const above for forward-compat;
|
||||
// omit from KnownWidgetKeys until the widget module lands.
|
||||
}
|
||||
|
||||
// WidgetSettingsSchema declares which knobs a widget exposes. nil = no
|
||||
// per-widget settings (the gear icon is hidden in edit mode).
|
||||
type WidgetSettingsSchema struct {
|
||||
// CountOptions lists permitted "count" values. Empty = no count knob.
|
||||
CountOptions []int
|
||||
// HorizonOptions lists permitted "horizon_days" values. Empty = no
|
||||
// horizon knob.
|
||||
HorizonOptions []int
|
||||
// CountAllowsAll is true when "all" is a legal value for count
|
||||
// (rendered as the literal -1 in the JSON). pinned-projects uses this.
|
||||
CountAllowsAll bool
|
||||
}
|
||||
|
||||
// Validate enforces the schema against a raw settings blob. nil schema
|
||||
// rejects any non-empty settings; empty settings always pass.
|
||||
func (sch *WidgetSettingsSchema) Validate(raw json.RawMessage) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
if sch == nil {
|
||||
return fmt.Errorf("%w: widget has no settings; got %s", ErrInvalidInput, string(raw))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
HorizonDays *int `json:"horizon_days,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("%w: widget settings decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
if parsed.Count != nil {
|
||||
if len(sch.CountOptions) == 0 {
|
||||
return fmt.Errorf("%w: widget has no count knob", ErrInvalidInput)
|
||||
}
|
||||
if !(sch.CountAllowsAll && *parsed.Count == -1) && !slices.Contains(sch.CountOptions, *parsed.Count) {
|
||||
return fmt.Errorf("%w: count %d not in %v", ErrInvalidInput, *parsed.Count, sch.CountOptions)
|
||||
}
|
||||
}
|
||||
if parsed.HorizonDays != nil {
|
||||
if len(sch.HorizonOptions) == 0 {
|
||||
return fmt.Errorf("%w: widget has no horizon knob", ErrInvalidInput)
|
||||
}
|
||||
if !slices.Contains(sch.HorizonOptions, *parsed.HorizonDays) {
|
||||
return fmt.Errorf("%w: horizon_days %d not in %v", ErrInvalidInput, *parsed.HorizonDays, sch.HorizonOptions)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WidgetDef is one entry in the catalog. Title/description fields are the
|
||||
// translation-key seeds; frontend resolves them via the i18n registry.
|
||||
type WidgetDef struct {
|
||||
Key WidgetKey `json:"key"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
DescriptionDE string `json:"description_de"`
|
||||
DescriptionEN string `json:"description_en"`
|
||||
DefaultVisible bool `json:"default_visible"`
|
||||
DefaultCount *int `json:"default_count,omitempty"`
|
||||
DefaultHorizon *int `json:"default_horizon_days,omitempty"`
|
||||
Settings *WidgetSettingsSchema `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// WidgetCatalog returns the v1 catalog. Returned by value (small struct
|
||||
// slice) so callers can freely append i18n overrides for the wire format.
|
||||
func WidgetCatalog() []WidgetDef {
|
||||
listCounts := []int{1, 3, 5, 10, 20}
|
||||
listHorizon := []int{7, 14, 30, 60}
|
||||
inboxCounts := []int{1, 3, 5, 10}
|
||||
agendaHorizon := []int{14, 30, 60}
|
||||
|
||||
tenDefault := 10
|
||||
threeDefault := 3
|
||||
thirtyDefault := 30
|
||||
|
||||
return []WidgetDef{
|
||||
{
|
||||
Key: WidgetDeadlineSummary,
|
||||
TitleDE: "Fristen auf einen Blick",
|
||||
TitleEN: "Deadlines at a glance",
|
||||
DescriptionDE: "Ampel-Karten für überfällige, heutige und kommende Fristen.",
|
||||
DescriptionEN: "Traffic-light cards for overdue, today, and upcoming deadlines.",
|
||||
DefaultVisible: true,
|
||||
},
|
||||
{
|
||||
Key: WidgetMatterSummary,
|
||||
TitleDE: "Meine Akten",
|
||||
TitleEN: "My Matters",
|
||||
DescriptionDE: "Aktiv-, archiviert- und Gesamtzahl deiner sichtbaren Akten.",
|
||||
DescriptionEN: "Active, archived and total counts of your visible matters.",
|
||||
DefaultVisible: true,
|
||||
},
|
||||
{
|
||||
Key: WidgetUpcomingDeadlines,
|
||||
TitleDE: "Kommende Fristen",
|
||||
TitleEN: "Upcoming deadlines",
|
||||
DescriptionDE: "Liste der nächsten Fristen — Anzahl und Zeitraum konfigurierbar.",
|
||||
DescriptionEN: "List of upcoming deadlines — count and horizon configurable.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &tenDefault,
|
||||
DefaultHorizon: &thirtyDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: listCounts,
|
||||
HorizonOptions: listHorizon,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetUpcomingAppointments,
|
||||
TitleDE: "Kommende Termine",
|
||||
TitleEN: "Upcoming appointments",
|
||||
DescriptionDE: "Liste der nächsten Termine — Anzahl und Zeitraum konfigurierbar.",
|
||||
DescriptionEN: "List of upcoming appointments — count and horizon configurable.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &tenDefault,
|
||||
DefaultHorizon: &thirtyDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: listCounts,
|
||||
HorizonOptions: listHorizon,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetInlineAgenda,
|
||||
TitleDE: "Agenda",
|
||||
TitleEN: "Agenda",
|
||||
DescriptionDE: "30-Tage-Agenda mit Fristen und Terminen kombiniert.",
|
||||
DescriptionEN: "30-day agenda combining deadlines and appointments.",
|
||||
DefaultVisible: true,
|
||||
DefaultHorizon: &thirtyDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
HorizonOptions: agendaHorizon,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetRecentActivity,
|
||||
TitleDE: "Letzte Aktivität",
|
||||
TitleEN: "Recent activity",
|
||||
DescriptionDE: "Verlauf der letzten Ereignisse in deinen sichtbaren Akten.",
|
||||
DescriptionEN: "Recent events across your visible matters.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &tenDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: listCounts,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: WidgetInboxApprovals,
|
||||
TitleDE: "Offene Freigaben",
|
||||
TitleEN: "Open approvals",
|
||||
DescriptionDE: "Deine offenen Freigaben mit Anzahl und einer kurzen Liste.",
|
||||
DescriptionEN: "Your open approval requests with count and a short list.",
|
||||
DefaultVisible: true,
|
||||
DefaultCount: &threeDefault,
|
||||
Settings: &WidgetSettingsSchema{
|
||||
CountOptions: inboxCounts,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupWidgetDef returns the catalog entry for a key, or false if unknown.
|
||||
func LookupWidgetDef(key WidgetKey) (WidgetDef, bool) {
|
||||
for _, def := range WidgetCatalog() {
|
||||
if def.Key == key {
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
return WidgetDef{}, false
|
||||
}
|
||||
Reference in New Issue
Block a user