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.
103 lines
3.7 KiB
Go
103 lines
3.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
|
|
// 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
|
|
dashboardShellBytes []byte
|
|
dashboardShellErr error
|
|
)
|
|
|
|
// loadDashboardShell reads dist/dashboard.html once. Server restarts on new
|
|
// builds (container-level) so a lifetime cache is safe.
|
|
func loadDashboardShell() ([]byte, error) {
|
|
dashboardShellOnce.Do(func() {
|
|
path := filepath.Join("dist", "dashboard.html")
|
|
dashboardShellBytes, dashboardShellErr = os.ReadFile(path)
|
|
if dashboardShellErr != nil {
|
|
return
|
|
}
|
|
if !bytes.Contains(dashboardShellBytes, []byte(dashboardDataPlaceholder)) {
|
|
log.Printf("warning: dashboard.html is missing the data placeholder — client will fall back to /api/dashboard")
|
|
}
|
|
})
|
|
return dashboardShellBytes, dashboardShellErr
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = 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.
|
|
func escapeForScript(b []byte) []byte {
|
|
b = bytes.ReplaceAll(b, []byte(`</`), []byte(`<\/`))
|
|
b = bytes.ReplaceAll(b, []byte("\u2028"), []byte(`\u2028`))
|
|
b = bytes.ReplaceAll(b, []byte("\u2029"), []byte(`\u2029`))
|
|
return b
|
|
}
|
|
|
|
// mustJSON encodes v, falling back to null on error (logged). Used only for
|
|
// server-side hydration where a broken payload is non-fatal — the client will
|
|
// fetch /api/dashboard as a fallback.
|
|
func mustJSON(v any) []byte {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
log.Printf("dashboard hydration encode: %v", err)
|
|
return nil
|
|
}
|
|
return b
|
|
}
|