Three issues from Slice B were entangled in the same root cause:
1. **Drag/drop reorder only swapped the first two same-size widgets.**
Widgets lived in two parents (.container + .dashboard-columns); the
old applyLayout used parent.appendChild per widget which physically
moved every .container widget to the END of .container — past the
.dashboard-columns row, edit-footer, and save-toast. Only the two
columns inside .dashboard-columns swapped visibly because they
shared a parent. Cross-row drags appeared to silently no-op.
2. **No resize affordance** — the design's per-widget sizing existed
only on paper.
3. **Per-widget options were thin** — count + horizon dropdowns only.
This change rebuilds the whole layout primitive on a single 12-column
CSS grid:
Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
calendar), inline-agenda (timeline, list), recent-activity (full,
compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
tracking row-max height so taller previous neighbours never overlap.
Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
wrapper; matter-summary converted to a CollapsibleSection so it
participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
grid-column / grid-row from computed placements. computePlacements
trusts explicit positions and auto-flows the rest with the same
rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
derived from live grid metrics, snaps to grid + clamps to schema,
autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
(x,y,w,h) so the spec stays self-describing — no mixed
explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
numeric inputs alongside preset dropdowns, size (W/H) + position
(X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
- upcoming-deadlines / -appointments: list (default) or mini-month
calendar with item dots.
- inline-agenda: timeline (default) or flat list.
- recent-activity: full (default) or compact (one-line per row).
CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.
Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
GridSizeOutsideClamps, NoOverlap (greedy packer regression),
AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
values inside [1,CountMax] are valid; presets stay valid too).
Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
269 lines
9.3 KiB
Go
269 lines
9.3 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) 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
|
|
}
|