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" // ShapeTimeline (t-paliad-177 Slice 4, faraday-Q7): cross-project // horizontal chart rendered by frontend/src/client/views/shape- // timeline-cv.ts on top of the same SVG renderer that powers // /projects/{id}/chart. Lane axis = project_id. Adapter is lossy: // ProjectionService projected rows are NOT surfaced (ViewService // doesn't run the calculator). UI tooltip on first open documents // the limitation. ShapeTimeline RenderShape = "timeline" ) // AllShapes lists every supported shape. Used by the validator and by // the in-page shape switcher. var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline} // 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"` Timeline *TimelineConfig `json:"timeline,omitempty"` } // TimelineConfig is the per-shape config for shape=timeline. Mirrors the // URL-state knobs of the standalone /projects/{id}/chart page: a saved // CV-timeline view bakes the user's chosen palette / density / range // preset into render_spec so reopening the view restores the same // visual. None are required — empty defaults match the standalone // chart's defaults (default palette, standard density, 1y range). type TimelineConfig struct { Palette string `json:"palette,omitempty"` Density string `json:"density,omitempty"` RangePreset string `json:"range_preset,omitempty"` RangeFrom string `json:"range_from,omitempty"` RangeTo string `json:"range_to,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). // // RowAction tells shape-list which row interaction to wire when the // universal renders the table. "navigate" (the default and // the contract for the existing /agenda/dashboard surfaces) routes a // row click to a per-kind detail page. "complete_toggle" is the // /events deadline-row pattern (checkbox + reopen button). "approve" // is the /inbox approver row (approve/reject buttons + revoke). "none" // is read-only (audit views, retrospective lists). // // shape-list.ts honours this when emitting the table's `entity-table` // classes — `entity-table--readonly` plus `none` skips the navigate // handler entirely. type ListConfig struct { Columns []string `json:"columns,omitempty"` Sort SortOrder `json:"sort,omitempty"` Density ListDensity `json:"density,omitempty"` RowAction ListRowAction `json:"row_action,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" ) // ListRowAction identifies which row interaction the list-shape renderer // should wire. Defaults to RowActionNavigate when empty so existing // SystemView definitions and saved user views continue to render rows // that route to the per-kind detail page. type ListRowAction string const ( RowActionNavigate ListRowAction = "navigate" RowActionCompleteToggle ListRowAction = "complete_toggle" RowActionApprove ListRowAction = "approve" RowActionInbox ListRowAction = "inbox" RowActionNone ListRowAction = "none" ) // KnownRowActions is the registry the validator checks against. Adding a // new action = add a const above AND append here AND extend // shape-list.ts's switch. var KnownRowActions = []ListRowAction{ RowActionNavigate, RowActionCompleteToggle, RowActionApprove, RowActionInbox, RowActionNone, } 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, ShapeTimeline: // 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 } } if s.Timeline != nil { if err := s.Timeline.validate(); err != nil { return err } } return nil } // KnownTimelinePalettes / Densities / Ranges mirror the frontend enums // in shape-timeline-chart.ts. Anything outside this set is rejected so // a stray value from an old build / hostile editor can't sneak into // stored render_spec rows. var ( knownTimelinePalettes = []string{ "default", "kind-coded", "track-coded", "high-contrast", "print", } knownTimelineDensities = []string{ "compact", "standard", "spacious", } knownTimelineRanges = []string{ "1y", "2y", "all", "custom", } ) func (c *TimelineConfig) validate() error { if c.Palette != "" && !slices.Contains(knownTimelinePalettes, c.Palette) { return fmt.Errorf("%w: unknown timeline.palette %q", ErrInvalidInput, c.Palette) } if c.Density != "" && !slices.Contains(knownTimelineDensities, c.Density) { return fmt.Errorf("%w: unknown timeline.density %q", ErrInvalidInput, c.Density) } if c.RangePreset != "" && !slices.Contains(knownTimelineRanges, c.RangePreset) { return fmt.Errorf("%w: unknown timeline.range_preset %q", ErrInvalidInput, c.RangePreset) } // RangeFrom / RangeTo are free-form ISO dates — the frontend regex- // checks them; here we only verify they're plain ASCII length-bounded // so a giant string can't bloat the jsonb column. if len(c.RangeFrom) > 32 { return fmt.Errorf("%w: timeline.range_from too long", ErrInvalidInput) } if len(c.RangeTo) > 32 { return fmt.Errorf("%w: timeline.range_to too long", ErrInvalidInput) } 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) } if c.RowAction != "" && !slices.Contains(KnownRowActions, c.RowAction) { return fmt.Errorf("%w: unknown list.row_action %q", ErrInvalidInput, c.RowAction) } 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 }