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.
This commit is contained in:
mAi
2026-05-20 13:44:49 +02:00
parent 0aa81139a3
commit a421bff856
7 changed files with 1006 additions and 0 deletions

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

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

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