Files
paliad/internal/services/dashboard_layout_spec.go
mAi 4cd2f05d33 fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode
Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.

Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
  pass places hidden widgets after the visible pass — collision-aware
  + cursor-aware so the hidden tray stacks below the active layout
  without ever displacing a visible widget. applyLayout() passes
  includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
  their stored coordinates so un-hiding restores them in place).

Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
  catalog Min/Max + grid bounds on load. Stale rows with W below MinW
  (or above MaxW, or X+W overflowing the grid) heal on the next
  /api/me/dashboard-layout GET and the cleaned spec is persisted
  back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
  exists to recover users who got into a bad state under the old
  rules.

Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
  behaviour (hidden skipped by default, two-pass ordering, multi-
  hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
  SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
  W=0 sentinel, negative X) plus a round-trip Validate guarantee.
2026-05-22 15:53:19 +02:00

343 lines
11 KiB
Go

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
}