Compare commits

..

8 Commits

Author SHA1 Message Date
mAi
aa435e5435 fix(verfahrensablauf): m/paliad#59 — restore click-to-edit on timeline dates
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.

Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:

  - openInlineDateEditor(span, onCommit) — swaps the date span for
    a `<input type=date>`, commits on blur/Enter, cancels on Escape,
    fires `onCommit(ruleCode, newValue)` ("" = revert).
  - wireDateEditClicks(container, onCommit) — idempotent delegated
    click + keyboard handler that resolves `.frist-date-edit
    [data-rule-code]` and opens the editor. Survives innerHTML
    rewrites because the listener lives on the container.

verfahrensablauf.ts now:
  - Owns its own anchorOverrides Map (cleared when proceeding-type
    changes — overrides for one proceeding don't apply to another).
  - Forwards overrides in calculateDeadlines() so downstream rules
    re-anchor on the user's date.
  - Passes `editable: true` to renderColumnsBody + renderTimelineBody.
  - Calls wireDateEditClicks() once on #timeline-container in
    DOMContentLoaded.

fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.

Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
2026-05-20 14:29:58 +02:00
mAi
3966394a39 Merge: t-paliad-219 Slice A — configurable dashboard backend + factory-default render
Slice A of the configurable user dashboard. Backend + factory layout served
on /dashboard; edit-mode + drag/drop come in Slice B.

- A1: paliad.user_dashboard_layouts storage (mig 109 — single-row-per-user
  PK, jsonb layout, RLS owner-only) + UserDashboardService CRUD.
- A2: HTTP handlers + service wiring (GET/PUT /api/user/dashboard).
- A3: widened server windows for the baseline widgets (deadlines 7d→60d
  LIMIT 10→40 with client-side filtering; activity similarly) +
  InboxSummary aggregate so the new inbox-approvals widget has data.
- A4: frontend widget dispatch + the 7 v1 widgets (6 baseline + inbox-
  approvals). 8th widget (pinned-projects) lands in Slice C, gated on the
  Slice C0 pin-machinery pre-req per m's Q3 deviation in the design doc.

Mig 109 lands cleanly via boltzmann's gap-tolerant applied-set tracker.
Edit mode (Anpassen toggle + drag/drop + per-widget settings) is Slice B.
2026-05-20 13:56:14 +02:00
mAi
5dacc97a6b feat(dashboard): t-paliad-219 Slice A4 — frontend widget dispatch + inbox-approvals
Wire the configurable dashboard end-to-end on the frontend side. Factory
render only (edit mode is Slice B).

dashboard.tsx:

- Add data-widget-key to every section that participates in the layout
  (deadline-summary, matter-summary, upcoming-deadlines, upcoming-
  appointments, inline-agenda, recent-activity, inbox-approvals).
- New inbox-approvals section markup with summary line, list, empty
  state, and full-inbox link.
- Triple hydration placeholder: data + layout + catalog spliced as
  separate window.__PALIAD_DASHBOARD_* globals.

dashboard_shell.go + dashboard.go:

- Three placeholder splice instead of one. splicePlaceholder() helper
  consolidates the JS-assignment encoding.
- handleDashboardPage pre-fetches the user's saved layout via
  dashboardLayout.GetOrSeed and inlines the WidgetCatalog (code-
  resident — always inlined so the widget picker can boot on knowledge-
  platform-only deploys too).

dashboard.ts client:

- New InboxSummary / InboxEntry / DashboardLayoutSpec / DashboardWidgetRef
  types mirroring the Go shapes.
- settingsFor(key) reads per-widget settings (count, horizon_days) from
  the active layout; defaults fall back to catalog values.
- Existing renderers (Deadlines, Appointments, Activity, Agenda) thread
  count + horizon settings — backend now returns 60d / LIMIT 40 so the
  client narrows per the user's widget config.
- New renderInbox() renders the inbox-approvals widget with summary
  copy ("N offene Freigaben warten auf dich"), top-N entry list, and
  the empty state.
- applyLayout() walks the saved spec and (a) hides widgets whose
  layout entry is visible:false and (b) reorders visible widgets via
  parent.appendChild within their existing parent — preserves the
  .dashboard-columns 2-up grid for deadlines+appointments.
- filterByHorizonDays() filters list items by date relative to today.
- Boot wiring: read __PALIAD_DASHBOARD_LAYOUT__ at mount; if missing,
  best-effort fetch /api/me/dashboard-layout and re-render once data
  has landed. Factory order baked into dashboard.tsx is the fallback
  so a hydration failure never breaks the dashboard.

i18n: 5 new keys per language for the inbox widget. 2528 → 2533.

go build + go vet + go test ./internal/... -short + bun run build all
clean. Triple placeholder verified present in dist/dashboard.html.

Pixel-identical factory render budget: every previously-visible widget
keeps its DOM markup, classes, IDs, and parent. New widget (inbox-
approvals) lands between agenda and activity per the factory layout
ordering in WidgetCatalog. Visible regression on the factory layout is
+1 section (inbox-approvals), expected per m's Q3 pick.
2026-05-20 13:55:56 +02:00
mAi
15bcba5d7c feat(dashboard): t-paliad-219 Slice A3 — widen windows + add InboxSummary
Two changes to DashboardService for the configurable dashboard:

1) Widen upcoming windows from 7d/LIMIT 10 → 60d/LIMIT 40 for both
   loadUpcomingDeadlines and loadUpcomingAppointments. Per design §18
   Note B, the per-widget horizon dropdown (7/14/30/60 days) filters
   client-side from a single payload — server-side widening preserves
   the Q4 "one big payload" pick without forcing per-widget endpoints.
   Existing tests pass: the dashboard CTE bucket math is unchanged and
   the wider rows-list is a superset of what /api/dashboard returned
   before.

2) Add InboxSummary { pending_count, top: []InboxEntry } to DashboardData
   for the new inbox-approvals widget (Q3 expansion). Powered by
   ApprovalService.PendingCountForUser + ListPendingForApprover with
   Limit=InboxTopCap (10). InboxEntry is the minimum needed to render
   a clickable preview line: request id, entity_type/title, project,
   requester, requested_at.

   ApprovalService is wired post-construction via
   DashboardService.SetApprovalService to avoid a circular constructor
   dependency. When unwired (knowledge-platform-only deployments,
   tests), loadInboxSummary is a no-op and the widget renders its
   empty state.

3 new pure-function tests: nil-approvals no-op, SetApprovalService
wiring, InboxTopCap sanity.

go build + go vet + go test ./internal/... -short all clean.
2026-05-20 13:55:56 +02:00
mAi
48f78a713b feat(dashboard): t-paliad-219 Slice A2 — HTTP handlers + service wiring
Four endpoints for the per-user dashboard layout:

- GET  /api/me/dashboard-layout         (auto-seeds factory on first call)
- PUT  /api/me/dashboard-layout         (validates against catalog)
- POST /api/me/dashboard-layout/reset   (overwrites with factory default)
- GET  /api/dashboard-widget-catalog    (catalog metadata for the picker)

Catalog endpoint is DB-independent by design — knowledge-platform-only
deployments (no DATABASE_URL) still surface the widget metadata. The
layout endpoints 503 when the service is unwired, matching the pattern
established by handleListCardLayouts / handleListPinnedProjects.

Wired through services.Services → handlers.dbServices via the
DashboardLayout field. main.go gains a single NewDashboardLayoutService
call next to NewCardLayoutService.

ErrInvalidInput from the service maps to 400; everything else flows
through writeServiceError for the existing 500/503 fallthrough.

go build + go vet + go test ./internal/services/ -short all clean.
2026-05-20 13:55:56 +02:00
mAi
a421bff856 feat(dashboard): t-paliad-219 Slice A1 — user_dashboard_layouts storage + service
Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.

Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (c85c382) lets any
embedded migration apply regardless of authoring order.

What ships:

- paliad.user_dashboard_layouts table: single-row PK on user_id (Q2 pick
  was single layout per user — no named-layout switcher). RLS owner-only,
  mirrors user_card_layouts / user_views patterns.
- DashboardLayoutSpec: { v: 1, widgets: [{ key, visible, settings? }] }.
  Validation is strict on write (catalog membership + per-widget settings
  schema, duplicate-key check, 32-widget cap, version pin). SanitizeForRead
  is forgiving — unknown keys dropped silently per design §10 versioning
  rule.
- DashboardLayoutService: GetOrSeed (auto-seeds factory default on first
  call, idempotent under concurrent first-load via ON CONFLICT DO NOTHING),
  Update (validates + upserts), ResetToDefault.
- WidgetCatalog: 7 v1 widget defs (deadline-summary, matter-summary,
  upcoming-deadlines, upcoming-appointments, inline-agenda, recent-activity,
  inbox-approvals). Per-widget WidgetSettingsSchema with CountOptions +
  HorizonOptions per design §18 Note B. pinned-projects const reserved
  but omitted from KnownWidgetKeys until Slice C lands its widget module.
- 18 pure-function tests pin: factory layout shape, validation failures
  (wrong version / over cap / unknown key / duplicate / bad settings),
  sanitize-on-read (drop unknown / noop on clean / bump version), JSON
  round-trip, catalog completeness, nil-schema behaviour.
- 4 live-DB tests (skipped without TEST_DATABASE_URL): GetOrSeed
  auto-seeds + idempotent, Update round-trips, Update rejects invalid,
  ResetToDefault overwrites.

Migration SQL dry-run live in BEGIN..ROLLBACK against supabase — clean.
go build + go test ./internal/services/ -short both clean.

Slice C0 (pin-machinery) from the design doc is OBSOLETE — paliad
.user_pinned_projects + PinService already exist (pre-dates t-paliad-219).
Slice C in the original plan becomes a single PR adding the
pinned-projects widget module that reads from the existing service.

Design: docs/design-dashboard-configurable-2026-05-20.md §5 + §18.
2026-05-20 13:55:56 +02:00
mAi
0aa81139a3 Merge: t-paliad-212 Slice 2c — MKCALENDAR + Google-degrade
Completes the CalDAV multi-calendar product. Slice 2 (a + b + c) is now
shipped end-to-end.

- mig 108 — user_caldav_config.supports_mkcalendar (tri-state: NULL=unprobed,
  TRUE=show create radio, FALSE=Google-degrade UX) + mkcalendar_probed_at.
  Capability lives on the server-creds row per Q2 — capability is per-server.
- POST /api/caldav-mkcalendar — issues MKCALENDAR + creates matching binding
  in one tx; 501 if probe=false; 409 on name conflict; 5xx upstream.
- caldav_client.go: OPTIONS probe (Allow: header parse) + synthetic
  fallback (MKCALENDAR against /.paliad-probe-<rand>/ then DELETE) for
  legacy SOGo / misconfigured Radicale that don't expose MKCALENDAR in
  Allow. Probe runs once + caches.
- 'Create new calendar' radio in the add-modal — visible only when
  supports_mkcalendar=TRUE. Slugifies display_name → calendar path with
  -N collision retry; gives up after 3 with 'pick a name yourself' error.
- Google-degrade UX (probe=FALSE): create-button hidden, bilingual notice
  surfaced, manual-URL input with PROPFIND-Depth-0 validation on submit,
  NO OAuth bounce.

t-paliad-212 complete: Slice 1 (mig 101 schema + bootstrap binding) +
Slice 2a (sync engine cut-over + mig 107 binding_id) + Slice 2b (write
APIs + picker UI) + Slice 2c (this). Hierarchy scopes
(client/litigation/patent/case) remain parked for Slice 3 per the master
design.
2026-05-20 13:26:45 +02:00
mAi
fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00
23 changed files with 1796 additions and 125 deletions

View File

@@ -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

View File

@@ -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)} &middot; ${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

View File

@@ -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);

View File

@@ -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",

View File

@@ -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;

View 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=");
});
});

View File

@@ -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) {

View File

@@ -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&auml;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&auml;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&auml;ndigen Posteingang &ouml;ffnen &rarr;</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&auml;t erfasst.

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
-- Reverse of 109_user_dashboard_layouts.up.sql.
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;

View 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());

View File

@@ -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

View 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())
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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
}

View 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
}

View 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))
}
}

View 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
}

View 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)
}
}

View File

@@ -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)

View 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)
}
}

View 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
}