Files
paliad/internal/handlers/dashboard.go
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

75 lines
2.4 KiB
Go

package handlers
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.
// Returns 503 if DATABASE_URL is unset.
func handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
data, err := dbSvc.dashboard.Get(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, data)
}
// 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, layout []byte
if hasUser && dbSvc != nil {
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)
}
}
}
// 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
// marketing landing; authenticated users get a 302 to /dashboard so `/` feels
// like a no-op they can bookmark.
func handleRootPage(w http.ResponseWriter, r *http.Request) {
if hasValidSession(r) {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
http.ServeFile(w, r, "dist/index.html")
}
// hasValidSession returns true when the session cookie carries a signed,
// unexpired Supabase JWT. Verification is the same one Middleware uses —
// forged or tampered cookies never reach the authenticated branch.
func hasValidSession(r *http.Request) bool {
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil || cookie.Value == "" {
return false
}
_, err = authClient.VerifyToken(cookie.Value)
return err == nil
}