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.
220 lines
7.7 KiB
Go
220 lines
7.7 KiB
Go
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
|
|
}
|