package services // RenderSpec is the structured render description that drives how a view // is presented. Same shape lives in paliad.user_views.render_spec (jsonb) // and is composed by the frontend's view editor. // // Design: docs/design-data-display-model-2026-05-06.md ยง4 (Q4-Q5). // // Q4 lock-in (m, 2026-05-07): three shapes โ€” list, cards, calendar. // "Activity" is content selection (sources + filters), not visualisation โ€” // achieved via shape="list", list.density="compact", list.columns= // ["time","actor","title","project"]. Same component, different config. import ( "encoding/json" "fmt" "slices" ) // RenderShape identifies which presentation component a view uses. type RenderShape string const ( ShapeList RenderShape = "list" ShapeCards RenderShape = "cards" ShapeCalendar RenderShape = "calendar" ) // AllShapes lists every supported shape. Used by the validator and by // the in-page shape switcher. var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar} // RenderSpec is the top-level render description. // // Per-shape config blocks are kept on save even when a different shape // is selected, so flipping back to a previously-used shape preserves // its tweaks (Q5 design decision). type RenderSpec struct { Shape RenderShape `json:"shape"` List *ListConfig `json:"list,omitempty"` Cards *CardsConfig `json:"cards,omitempty"` Calendar *CalendarConfig `json:"calendar,omitempty"` } // ListConfig is the per-shape config for shape=list. Powers both the // /events table look (density=comfortable) and the activity-feed look // (density=compact + actor/time columns). type ListConfig struct { Columns []string `json:"columns,omitempty"` Sort SortOrder `json:"sort,omitempty"` Density ListDensity `json:"density,omitempty"` } // CardsConfig is the per-shape config for shape=cards. type CardsConfig struct { GroupBy CardsGroupBy `json:"group_by,omitempty"` Sort SortOrder `json:"sort,omitempty"` ShowEmptyDays bool `json:"show_empty_days,omitempty"` } // CalendarConfig is the per-shape config for shape=calendar. type CalendarConfig struct { DefaultView CalendarView `json:"default_view,omitempty"` ShowWeekends bool `json:"show_weekends,omitempty"` } type SortOrder string const ( SortDateAsc SortOrder = "date_asc" SortDateDesc SortOrder = "date_desc" ) type ListDensity string const ( DensityComfortable ListDensity = "comfortable" DensityCompact ListDensity = "compact" ) type CardsGroupBy string const ( CardsGroupByDay CardsGroupBy = "day" CardsGroupByWeek CardsGroupBy = "week" CardsGroupByNone CardsGroupBy = "none" ) type CalendarView string const ( CalendarMonth CalendarView = "month" CalendarWeek CalendarView = "week" ) // KnownListColumns is the curated set of column keys the list-shape // renderer understands. Validator rejects anything else so a typo in // the editor surfaces immediately. var KnownListColumns = []string{ "date", "time", "title", "project", "actor", "status", "rule", "event_type", "location", "appointment_type", "approval_status", "decided_by", "kind", } // Validate runs the full RenderSpec contract. func (s *RenderSpec) Validate() error { if s == nil { return fmt.Errorf("%w: render_spec is required", ErrInvalidInput) } switch s.Shape { case ShapeList, ShapeCards, ShapeCalendar: // fine default: return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape) } if s.List != nil { if err := s.List.validate(); err != nil { return err } } if s.Cards != nil { if err := s.Cards.validate(); err != nil { return err } } if s.Calendar != nil { if err := s.Calendar.validate(); err != nil { return err } } return nil } func (c *ListConfig) validate() error { for _, col := range c.Columns { if !slices.Contains(KnownListColumns, col) { return fmt.Errorf("%w: unknown list.columns value %q", ErrInvalidInput, col) } } switch c.Sort { case "", SortDateAsc, SortDateDesc: default: return fmt.Errorf("%w: unknown list.sort %q", ErrInvalidInput, c.Sort) } switch c.Density { case "", DensityComfortable, DensityCompact: default: return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density) } return nil } func (c *CardsConfig) validate() error { switch c.GroupBy { case "", CardsGroupByDay, CardsGroupByWeek, CardsGroupByNone: default: return fmt.Errorf("%w: unknown cards.group_by %q", ErrInvalidInput, c.GroupBy) } switch c.Sort { case "", SortDateAsc, SortDateDesc: default: return fmt.Errorf("%w: unknown cards.sort %q", ErrInvalidInput, c.Sort) } return nil } func (c *CalendarConfig) validate() error { switch c.DefaultView { case "", CalendarMonth, CalendarWeek: default: return fmt.Errorf("%w: unknown calendar.default_view %q", ErrInvalidInput, c.DefaultView) } return nil } // DefaultRenderSpec returns a minimal valid spec โ€” used as the seed when // the editor opens with a blank canvas. func DefaultRenderSpec() RenderSpec { return RenderSpec{ Shape: ShapeList, List: &ListConfig{ Sort: SortDateAsc, Density: DensityComfortable, }, } } // MarshalRenderSpec validates + encodes for storage. func MarshalRenderSpec(s RenderSpec) ([]byte, error) { if err := s.Validate(); err != nil { return nil, err } return json.Marshal(s) } // UnmarshalRenderSpec parses + validates. func UnmarshalRenderSpec(b []byte) (RenderSpec, error) { var s RenderSpec if err := json.Unmarshal(b, &s); err != nil { return RenderSpec{}, fmt.Errorf("%w: render_spec malformed: %v", ErrInvalidInput, err) } if err := s.Validate(); err != nil { return RenderSpec{}, err } return s, nil }