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 // DashboardGridColumns is the column count of the dashboard layout grid. The CSS // `.dashboard-grid` template is `repeat(DashboardGridColumns, 1fr)` and the // validator caps X+W ≤ DashboardGridColumns. Twelve is the industry-standard // dashboard grain — supports halves, thirds, quarters, sixths. const DashboardGridColumns = 12 // MaxGridRowSpan caps how tall a single widget can grow. Five vertical // cells is enough for a fully-expanded calendar without letting a // runaway resize fill the entire viewport. const MaxGridRowSpan = 5 // 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. // // Position fields (X/Y/W/H) carry the widget's slot in the 12-column grid. // X is 0-indexed column-start (0..DashboardGridColumns-1); Y is 0-indexed row-start. // W is column span (1..DashboardGridColumns); H is row span (1..MaxGridRowSpan). // When W=0 the widget is treated as full-width (W=DashboardGridColumns); H=0 // means H=1. This keeps pre-overhaul layouts (no positions on the wire) // rendering sensibly under the new grid — they get auto-placed full- // width in array order. type DashboardWidgetRef struct { Key WidgetKey `json:"key"` Visible bool `json:"visible"` X int `json:"x,omitempty"` Y int `json:"y,omitempty"` W int `json:"w,omitempty"` H int `json:"h,omitempty"` 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, in canonical order, with per-widget default // settings + grid positions drawn from the catalog. Visible widgets get // placed row-by-row using a greedy left-to-right packer (next widget // goes into the leftmost slot wide enough on the current row, else // wraps to a new row). Hidden widgets carry default sizes but no // position — they get one when re-added via the picker. 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)) // Greedy packer: place each visible widget left-to-right on the // current row. When the widget doesn't fit, wrap to a new row at y // = max-row-height-so-far. rowMaxH tracks the tallest widget in the // row currently being filled — wrapping by only the new widget's // height would let taller previous neighbours overlap. cursorX is // the next free column on the current row. cursorX, cursorY, rowMaxH := 0, 0, 0 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 } w := def.DefaultW if w <= 0 || w > DashboardGridColumns { w = DashboardGridColumns } h := def.DefaultH if h <= 0 { h = 1 } ref.W = w ref.H = h if def.DefaultVisible { if cursorX+w > DashboardGridColumns { cursorY += rowMaxH cursorX = 0 rowMaxH = 0 } ref.X = cursorX ref.Y = cursorY cursorX += w if h > rowMaxH { rowMaxH = h } } 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) } if err := validatePosition(i, w, def); err != nil { return err } } return nil } // validatePosition checks grid X/Y/W/H against schema clamps. Zero // values are accepted (auto-flow + default size); non-zero values must // fit the 12-column grid and the widget's MinW/MaxW/MinH/MaxH clamps. func validatePosition(i int, w DashboardWidgetRef, def WidgetDef) error { if w.X < 0 || w.X >= DashboardGridColumns { return fmt.Errorf("%w: widgets[%d].x %d outside [0,%d)", ErrInvalidInput, i, w.X, DashboardGridColumns) } if w.Y < 0 { return fmt.Errorf("%w: widgets[%d].y %d must be >= 0", ErrInvalidInput, i, w.Y) } if w.W < 0 || w.W > DashboardGridColumns { return fmt.Errorf("%w: widgets[%d].w %d outside [0,%d]", ErrInvalidInput, i, w.W, DashboardGridColumns) } if w.W > 0 && w.X+w.W > DashboardGridColumns { return fmt.Errorf("%w: widgets[%d] x+w (%d) overflows grid (%d)", ErrInvalidInput, i, w.X+w.W, DashboardGridColumns) } if w.H < 0 || w.H > MaxGridRowSpan { return fmt.Errorf("%w: widgets[%d].h %d outside [0,%d]", ErrInvalidInput, i, w.H, MaxGridRowSpan) } if def.MinW > 0 && w.W > 0 && w.W < def.MinW { return fmt.Errorf("%w: widgets[%d].w %d below MinW=%d", ErrInvalidInput, i, w.W, def.MinW) } if def.MaxW > 0 && w.W > def.MaxW { return fmt.Errorf("%w: widgets[%d].w %d above MaxW=%d", ErrInvalidInput, i, w.W, def.MaxW) } if def.MinH > 0 && w.H > 0 && w.H < def.MinH { return fmt.Errorf("%w: widgets[%d].h %d below MinH=%d", ErrInvalidInput, i, w.H, def.MinH) } if def.MaxH > 0 && w.H > def.MaxH { return fmt.Errorf("%w: widgets[%d].h %d above MaxH=%d", ErrInvalidInput, i, w.H, def.MaxH) } return nil } // SanitizeForRead applies the forgiving read-path rules: drop entries whose // keys are not in the catalog (catalog has shrunk), bump the version to // the current one if missing, and clamp w/h/x against the catalog's // MinW/MaxW/MinH/MaxH/grid bounds so a stale row with out-of-range sizes // can't strand the user with unrenderable widgets (m/paliad#73). 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 { def, ok := LookupWidgetDef(w.Key) if !ok { changed = true continue } if normalizePosition(&w, def) { changed = true } out = append(out, w) } s.Widgets = out return changed } // normalizePosition clamps a widget's W/H/X to the catalog bounds and the // grid extent. Returns true if any field was modified. Zero W/H stay zero // (auto-flow / default sentinel — the placer fills them in). Negative X // snaps to 0; X+W overflowing the grid snaps X down. func normalizePosition(w *DashboardWidgetRef, def WidgetDef) bool { changed := false if w.W < 0 { w.W = 0 changed = true } if w.W > DashboardGridColumns { w.W = DashboardGridColumns changed = true } // W == 0 is the "auto / default" sentinel — leave it untouched so // downstream renderers can substitute DefaultW. Only clamp non-zero // values against the per-widget Min/Max. if w.W > 0 { if def.MinW > 0 && w.W < def.MinW { w.W = def.MinW changed = true } if def.MaxW > 0 && w.W > def.MaxW { w.W = def.MaxW changed = true } } if w.H < 0 { w.H = 0 changed = true } if w.H > MaxGridRowSpan { w.H = MaxGridRowSpan changed = true } if w.H > 0 { if def.MinH > 0 && w.H < def.MinH { w.H = def.MinH changed = true } if def.MaxH > 0 && w.H > def.MaxH { w.H = def.MaxH changed = true } } if w.X < 0 { w.X = 0 changed = true } if w.X >= DashboardGridColumns { w.X = DashboardGridColumns - 1 changed = true } if w.W > 0 && w.X+w.W > DashboardGridColumns { w.X = DashboardGridColumns - w.W changed = true } if w.Y < 0 { w.Y = 0 changed = true } 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 }