Files
paliad/internal/handlers/dashboard_shell.go
m b79ef258ef feat(dashboard): Phase G — logged-in landing page
New /dashboard route serves the authenticated home screen with a
server-rendered payload (no skeleton→fetch waterfall, per design
audit §2.3). / now redirects authenticated visitors to /dashboard
and keeps the marketing landing for anonymous visitors.

- DashboardService aggregates deadline + matter summaries, the next
  7d of Fristen/Termine, and the last 10 akten_events, all scoped
  by the standard office-visibility predicate.
- Dashboard handler splices the JSON payload into dist/dashboard.html
  as window.__PALIAD_DASHBOARD__ so the client paints on first frame;
  client re-fetches /api/dashboard every 60s to stay current.
- Sidebar gains an "Übersicht" group with the Dashboard entry at the
  top; DE/EN i18n keys + traffic-light card styles added.
- Empty-state copy, onboarding hint, and 503 handling keep the page
  intact when DATABASE_URL is unset.
2026-04-16 17:27:42 +02:00

91 lines
3.1 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 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__*/"
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 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) {
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)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// 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
}