package services // LayoutSpec — JSON shape for paliad.user_card_layouts.layout_json. // // Design: docs/design-projects-page-2026-05-07.md §5b.3. // // Validation surface (server-side): // - Every fact key must be in KnownFactKeys. // - Each key appears at most once (no duplicates). // - "title-row" must be the first visible fact (always-on, structural). // - count is bounded [1, 5] when set (only meaningful for next-events / // recent-verlauf). // - density ∈ {compact, roomy}. // - gridColumns ∈ {auto, 2, 3, 4}. // // JSON shape mirrors the TypeScript type in // frontend/src/client/projects-cards-types.ts. import ( "encoding/json" "fmt" "slices" ) // FactKey enumerates the cards facts the user can show / hide / reorder. type FactKey string const ( FactTitleRow FactKey = "title-row" FactTypeChip FactKey = "type-chip" FactStatusChip FactKey = "status-chip" FactClientMatter FactKey = "client-matter" FactParentPath FactKey = "parent-path" FactDeadlineCounts FactKey = "deadline-counts" FactNextEvents FactKey = "next-events" FactRecentVerlauf FactKey = "recent-verlauf" FactTeamChips FactKey = "team-chips" FactReference FactKey = "reference" FactLastActivityAt FactKey = "last-activity-at" ) // KnownFactKeys is the registry. Adding a new fact = add a const above // AND append here. Frontend has its own mirror in projects-cards.ts. var KnownFactKeys = []FactKey{ FactTitleRow, FactTypeChip, FactStatusChip, FactClientMatter, FactParentPath, FactDeadlineCounts, FactNextEvents, FactRecentVerlauf, FactTeamChips, FactReference, FactLastActivityAt, } // CardDensity controls per-card padding + line-height (Kompakt / Geräumig). // Distinct type from t-paliad-144's ListDensity (which only ranges over // {comfortable, compact} and applies to the views.list render shape). type CardDensity string const ( CardDensityCompact CardDensity = "compact" CardDensityRoomy CardDensity = "roomy" ) // GridColumns controls the responsive grid. "auto" lets the browser // fit-as-many-as-possible at minmax(280px, 1fr); 2/3/4 force fixed columns. type GridColumns string const ( GridAuto GridColumns = "auto" GridTwo GridColumns = "2" GridThree GridColumns = "3" GridFour GridColumns = "4" ) // LayoutFact is a single fact entry in the ordered facts[] array. type LayoutFact struct { Key FactKey `json:"key"` Visible bool `json:"visible"` // Count is meaningful for next-events and recent-verlauf only. nil for // every other key. Bounded [1, 5] when set; default 3 (the seed value). Count *int `json:"count,omitempty"` } // LayoutSpec is the persisted card-layout shape. type LayoutSpec struct { Facts []LayoutFact `json:"facts"` Density CardDensity `json:"density"` GridColumns GridColumns `json:"grid_columns"` ShowAllLevels bool `json:"show_all_levels"` } // DefaultLayoutSpec returns the seed "Standard" layout per design §5b.4 — // rich content set, all 9 facts visible, roomy density, auto grid. func DefaultLayoutSpec() LayoutSpec { three := 3 return LayoutSpec{ Facts: []LayoutFact{ {Key: FactTitleRow, Visible: true}, {Key: FactTypeChip, Visible: true}, {Key: FactStatusChip, Visible: true}, {Key: FactClientMatter, Visible: true}, {Key: FactParentPath, Visible: true}, {Key: FactDeadlineCounts, Visible: true}, {Key: FactNextEvents, Visible: true, Count: &three}, {Key: FactRecentVerlauf, Visible: true, Count: &three}, {Key: FactTeamChips, Visible: true}, }, Density: CardDensityRoomy, GridColumns: GridAuto, ShowAllLevels: false, } } // Validate enforces the structural invariants. Returns ErrInvalidInput // wrapped with a precise message on the first violation. func (s LayoutSpec) Validate() error { if len(s.Facts) == 0 { return fmt.Errorf("%w: layout.facts is empty", ErrInvalidInput) } // First visible fact must be title-row. firstVisible := -1 for i, f := range s.Facts { if f.Visible { firstVisible = i break } } if firstVisible == -1 { return fmt.Errorf("%w: layout has no visible facts", ErrInvalidInput) } if s.Facts[firstVisible].Key != FactTitleRow { return fmt.Errorf("%w: first visible fact must be %q (got %q)", ErrInvalidInput, FactTitleRow, s.Facts[firstVisible].Key) } seen := make(map[FactKey]bool, len(s.Facts)) for i, f := range s.Facts { if !slices.Contains(KnownFactKeys, f.Key) { return fmt.Errorf("%w: layout.facts[%d].key %q is not a known fact", ErrInvalidInput, i, f.Key) } if seen[f.Key] { return fmt.Errorf("%w: layout.facts has duplicate key %q", ErrInvalidInput, f.Key) } seen[f.Key] = true if f.Count != nil { if f.Key != FactNextEvents && f.Key != FactRecentVerlauf { return fmt.Errorf("%w: layout.facts[%d] count is only valid for next-events / recent-verlauf", ErrInvalidInput, i) } if *f.Count < 1 || *f.Count > 5 { return fmt.Errorf("%w: layout.facts[%d].count %d out of range [1, 5]", ErrInvalidInput, i, *f.Count) } } } switch s.Density { case CardDensityCompact, CardDensityRoomy: default: return fmt.Errorf("%w: layout.density %q invalid", ErrInvalidInput, s.Density) } switch s.GridColumns { case GridAuto, GridTwo, GridThree, GridFour: default: return fmt.Errorf("%w: layout.grid_columns %q invalid", ErrInvalidInput, s.GridColumns) } return nil } // ParseLayoutSpec decodes JSON bytes and validates. Used both by the HTTP // handler (request body) and by the service (read-back from the DB column). func ParseLayoutSpec(b []byte) (LayoutSpec, error) { var s LayoutSpec if err := json.Unmarshal(b, &s); err != nil { return LayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err) } if err := s.Validate(); err != nil { return LayoutSpec{}, err } return s, nil }