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:
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 109_user_dashboard_layouts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;
|
||||
29
internal/db/migrations/109_user_dashboard_layouts.up.sql
Normal file
29
internal/db/migrations/109_user_dashboard_layouts.up.sql
Normal 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());
|
||||
157
internal/services/dashboard_layout_service.go
Normal file
157
internal/services/dashboard_layout_service.go
Normal 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
|
||||
}
|
||||
181
internal/services/dashboard_layout_service_test.go
Normal file
181
internal/services/dashboard_layout_service_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
176
internal/services/dashboard_layout_spec.go
Normal file
176
internal/services/dashboard_layout_spec.go
Normal 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
|
||||
}
|
||||
241
internal/services/dashboard_layout_spec_test.go
Normal file
241
internal/services/dashboard_layout_spec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
219
internal/services/widget_catalog.go
Normal file
219
internal/services/widget_catalog.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user