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.
408 lines
14 KiB
Go
408 lines
14 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"
|
|
WidgetQuickActions WidgetKey = "quick-actions"
|
|
)
|
|
|
|
// KnownWidgetKeys is the canonical order used when seeding the factory
|
|
// default layout. New entries land at the bottom by default.
|
|
//
|
|
// Slice C activated WidgetPinnedProjects (reusing the pin-machinery
|
|
// PinService that pre-dates t-paliad-219) and added WidgetQuickActions
|
|
// (pure UI; no backend data path) per m's brief on catalog expansion.
|
|
var KnownWidgetKeys = []WidgetKey{
|
|
WidgetDeadlineSummary,
|
|
WidgetMatterSummary,
|
|
WidgetUpcomingDeadlines,
|
|
WidgetUpcomingAppointments,
|
|
WidgetInlineAgenda,
|
|
WidgetRecentActivity,
|
|
WidgetInboxApprovals,
|
|
WidgetPinnedProjects,
|
|
WidgetQuickActions,
|
|
}
|
|
|
|
// ViewOption is one entry in a widget's "view" knob — a presentation
|
|
// variant the widget supports (e.g. list vs calendar for upcoming-
|
|
// deadlines). The ID is what's persisted in user settings; the frontend
|
|
// picks the renderer based on it.
|
|
type ViewOption struct {
|
|
ID string `json:"id"`
|
|
LabelDE string `json:"label_de"`
|
|
LabelEN string `json:"label_en"`
|
|
}
|
|
|
|
// 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
|
|
// CountMax is an upper bound for the "count" knob when the gear pane
|
|
// exposes a free-form numeric input alongside the dropdown. Zero =
|
|
// dropdown-only (legacy). When non-zero, the validator accepts any
|
|
// integer in [1, CountMax] in addition to entries in CountOptions.
|
|
CountMax int
|
|
// HorizonMax is the analogue for "horizon_days". Zero = dropdown-only.
|
|
HorizonMax int
|
|
// Views lists the supported presentation variants for the widget. Empty
|
|
// = the widget has a single hardcoded renderer (no view picker).
|
|
Views []ViewOption
|
|
}
|
|
|
|
// rawWidgetSettings is the typed projection of the JSON we accept. New
|
|
// knobs land here; the validator + frontend gear pane stay in lock-step.
|
|
type rawWidgetSettings struct {
|
|
Count *int `json:"count,omitempty"`
|
|
HorizonDays *int `json:"horizon_days,omitempty"`
|
|
View *string `json:"view,omitempty"`
|
|
}
|
|
|
|
// 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 rawWidgetSettings
|
|
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 && sch.CountMax == 0 {
|
|
return fmt.Errorf("%w: widget has no count knob", ErrInvalidInput)
|
|
}
|
|
ok := false
|
|
if sch.CountAllowsAll && *parsed.Count == -1 {
|
|
ok = true
|
|
}
|
|
if !ok && slices.Contains(sch.CountOptions, *parsed.Count) {
|
|
ok = true
|
|
}
|
|
if !ok && sch.CountMax > 0 && *parsed.Count >= 1 && *parsed.Count <= sch.CountMax {
|
|
ok = true
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: count %d not in %v (max %d)", ErrInvalidInput, *parsed.Count, sch.CountOptions, sch.CountMax)
|
|
}
|
|
}
|
|
if parsed.HorizonDays != nil {
|
|
if len(sch.HorizonOptions) == 0 && sch.HorizonMax == 0 {
|
|
return fmt.Errorf("%w: widget has no horizon knob", ErrInvalidInput)
|
|
}
|
|
ok := false
|
|
if slices.Contains(sch.HorizonOptions, *parsed.HorizonDays) {
|
|
ok = true
|
|
}
|
|
if !ok && sch.HorizonMax > 0 && *parsed.HorizonDays >= 1 && *parsed.HorizonDays <= sch.HorizonMax {
|
|
ok = true
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: horizon_days %d not in %v (max %d)", ErrInvalidInput, *parsed.HorizonDays, sch.HorizonOptions, sch.HorizonMax)
|
|
}
|
|
}
|
|
if parsed.View != nil {
|
|
if len(sch.Views) == 0 {
|
|
return fmt.Errorf("%w: widget has no view knob", ErrInvalidInput)
|
|
}
|
|
ok := false
|
|
for _, v := range sch.Views {
|
|
if v.ID == *parsed.View {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: view %q not in catalog", ErrInvalidInput, *parsed.View)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WidgetDef is one entry in the catalog. Title/description fields are the
|
|
// translation-key seeds; frontend resolves them via the i18n registry.
|
|
//
|
|
// Default size (W/H) drives both the factory layout and the resize
|
|
// clamp on the gear pane. W is grid columns 1..DashboardGridColumns; H is row
|
|
// span 1..N. Zero defaults are treated as W=DashboardGridColumns, H=1.
|
|
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"`
|
|
DefaultView string `json:"default_view,omitempty"`
|
|
DefaultW int `json:"default_w,omitempty"`
|
|
DefaultH int `json:"default_h,omitempty"`
|
|
MinW int `json:"min_w,omitempty"`
|
|
MaxW int `json:"max_w,omitempty"`
|
|
MinH int `json:"min_h,omitempty"`
|
|
MaxH int `json:"max_h,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.
|
|
//
|
|
// Sizes use a 12-column grid (see DashboardGridColumns). Each widget declares its
|
|
// preferred default size (W/H) plus min/max clamps that the resize handle
|
|
// honours. Catalog defaults are tuned to fill the 12-col grid sensibly
|
|
// on first load — see FactoryDefaultLayout for the assembled flow.
|
|
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
|
|
|
|
listOrCalendar := []ViewOption{
|
|
{ID: "list", LabelDE: "Liste", LabelEN: "List"},
|
|
{ID: "calendar", LabelDE: "Kalender", LabelEN: "Calendar"},
|
|
}
|
|
activityViews := []ViewOption{
|
|
{ID: "full", LabelDE: "Ausführlich", LabelEN: "Full"},
|
|
{ID: "compact", LabelDE: "Kompakt", LabelEN: "Compact"},
|
|
}
|
|
agendaViews := []ViewOption{
|
|
{ID: "timeline", LabelDE: "Zeitachse", LabelEN: "Timeline"},
|
|
{ID: "list", LabelDE: "Liste", LabelEN: "List"},
|
|
}
|
|
|
|
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,
|
|
DefaultW: 12,
|
|
DefaultH: 1,
|
|
MinW: 6,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 2,
|
|
},
|
|
{
|
|
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,
|
|
DefaultW: 6,
|
|
DefaultH: 1,
|
|
MinW: 4,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 1,
|
|
},
|
|
{
|
|
Key: WidgetUpcomingDeadlines,
|
|
TitleDE: "Kommende Fristen",
|
|
TitleEN: "Upcoming deadlines",
|
|
DescriptionDE: "Liste der nächsten Fristen — Anzahl, Zeitraum und Darstellung konfigurierbar.",
|
|
DescriptionEN: "List of upcoming deadlines — count, horizon and view configurable.",
|
|
DefaultVisible: true,
|
|
DefaultCount: &tenDefault,
|
|
DefaultHorizon: &thirtyDefault,
|
|
DefaultView: "list",
|
|
DefaultW: 6,
|
|
DefaultH: 2,
|
|
MinW: 4,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 4,
|
|
Settings: &WidgetSettingsSchema{
|
|
CountOptions: listCounts,
|
|
CountMax: 50,
|
|
HorizonOptions: listHorizon,
|
|
HorizonMax: 365,
|
|
Views: listOrCalendar,
|
|
},
|
|
},
|
|
{
|
|
Key: WidgetUpcomingAppointments,
|
|
TitleDE: "Kommende Termine",
|
|
TitleEN: "Upcoming appointments",
|
|
DescriptionDE: "Liste der nächsten Termine — Anzahl, Zeitraum und Darstellung konfigurierbar.",
|
|
DescriptionEN: "List of upcoming appointments — count, horizon and view configurable.",
|
|
DefaultVisible: true,
|
|
DefaultCount: &tenDefault,
|
|
DefaultHorizon: &thirtyDefault,
|
|
DefaultView: "list",
|
|
DefaultW: 6,
|
|
DefaultH: 2,
|
|
MinW: 4,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 4,
|
|
Settings: &WidgetSettingsSchema{
|
|
CountOptions: listCounts,
|
|
CountMax: 50,
|
|
HorizonOptions: listHorizon,
|
|
HorizonMax: 365,
|
|
Views: listOrCalendar,
|
|
},
|
|
},
|
|
{
|
|
Key: WidgetInlineAgenda,
|
|
TitleDE: "Agenda",
|
|
TitleEN: "Agenda",
|
|
DescriptionDE: "Agenda mit Fristen und Terminen kombiniert — Zeitraum und Darstellung konfigurierbar.",
|
|
DescriptionEN: "Agenda combining deadlines and appointments — horizon and view configurable.",
|
|
DefaultVisible: true,
|
|
DefaultHorizon: &thirtyDefault,
|
|
DefaultView: "timeline",
|
|
DefaultW: 12,
|
|
DefaultH: 2,
|
|
MinW: 6,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 4,
|
|
Settings: &WidgetSettingsSchema{
|
|
HorizonOptions: agendaHorizon,
|
|
HorizonMax: 365,
|
|
Views: agendaViews,
|
|
},
|
|
},
|
|
{
|
|
Key: WidgetRecentActivity,
|
|
TitleDE: "Letzte Aktivität",
|
|
TitleEN: "Recent activity",
|
|
DescriptionDE: "Verlauf der letzten Ereignisse — Anzahl und Darstellung konfigurierbar.",
|
|
DescriptionEN: "Recent events across your visible matters — count and view configurable.",
|
|
DefaultVisible: true,
|
|
DefaultCount: &tenDefault,
|
|
DefaultView: "full",
|
|
DefaultW: 12,
|
|
DefaultH: 2,
|
|
MinW: 4,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 4,
|
|
Settings: &WidgetSettingsSchema{
|
|
CountOptions: listCounts,
|
|
CountMax: 50,
|
|
Views: activityViews,
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
DefaultW: 6,
|
|
DefaultH: 1,
|
|
MinW: 4,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 2,
|
|
Settings: &WidgetSettingsSchema{
|
|
CountOptions: inboxCounts,
|
|
CountMax: 50,
|
|
},
|
|
},
|
|
// Slice C: pinned-projects rides on the pre-existing PinService
|
|
// (paliad.user_pinned_projects, mig 062/063 — pre-dates this
|
|
// task). DefaultVisible=false so existing users don't get a new
|
|
// widget injected unannounced; they opt in via the picker.
|
|
{
|
|
Key: WidgetPinnedProjects,
|
|
TitleDE: "Angepinnte Akten",
|
|
TitleEN: "Pinned matters",
|
|
DescriptionDE: "Schneller Zugriff auf deine angepinnten Akten.",
|
|
DescriptionEN: "Quick access to your pinned matters.",
|
|
DefaultVisible: false,
|
|
DefaultCount: &tenDefault,
|
|
DefaultW: 6,
|
|
DefaultH: 2,
|
|
MinW: 4,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 4,
|
|
Settings: &WidgetSettingsSchema{
|
|
CountOptions: listCounts,
|
|
CountAllowsAll: true,
|
|
CountMax: 50,
|
|
},
|
|
},
|
|
// Slice C: quick-actions is pure UI — no backend payload, no
|
|
// settings. Renders 3 affordances ("+ Akte", "+ Frist",
|
|
// "+ Termin") that link to the existing create surfaces.
|
|
{
|
|
Key: WidgetQuickActions,
|
|
TitleDE: "Schnellzugriff",
|
|
TitleEN: "Quick actions",
|
|
DescriptionDE: "Direkte Buttons für neue Akten, Fristen und Termine.",
|
|
DescriptionEN: "Direct buttons for new matters, deadlines, and appointments.",
|
|
DefaultVisible: false,
|
|
DefaultW: 12,
|
|
DefaultH: 1,
|
|
MinW: 6,
|
|
MaxW: 12,
|
|
MinH: 1,
|
|
MaxH: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|