feat(t-paliad-177): ShapeTimeline enum + render_spec wiring

Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.

Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
  can't bloat the jsonb column.

Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.

Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
This commit is contained in:
mAi
2026-05-15 00:06:37 +02:00
parent 79f6be3fc9
commit 83a3d27fe0
2 changed files with 102 additions and 6 deletions

View File

@@ -24,11 +24,19 @@ 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}
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
// RenderSpec is the top-level render description.
//
@@ -36,10 +44,25 @@ var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
// 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"`
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
@@ -144,7 +167,7 @@ func (s *RenderSpec) Validate() error {
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
}
switch s.Shape {
case ShapeList, ShapeCards, ShapeCalendar:
case ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline:
// fine
default:
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
@@ -165,6 +188,49 @@ func (s *RenderSpec) Validate() error {
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
}

View File

@@ -15,7 +15,7 @@ func TestRenderSpec_HappyPath(t *testing.T) {
}
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
for _, sh := range cases {
t.Run(string(sh), func(t *testing.T) {
s := RenderSpec{Shape: sh}
@@ -26,6 +26,36 @@ func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
}
}
func TestRenderSpec_TimelineConfigValidates(t *testing.T) {
cases := []struct {
name string
cfg TimelineConfig
ok bool
}{
{"empty defaults are fine", TimelineConfig{}, true},
{"known palette", TimelineConfig{Palette: "kind-coded"}, true},
{"known density", TimelineConfig{Density: "compact"}, true},
{"known range preset", TimelineConfig{RangePreset: "2y"}, true},
{"custom range with bounds", TimelineConfig{RangePreset: "custom", RangeFrom: "2026-01-01", RangeTo: "2026-12-31"}, true},
{"unknown palette rejects", TimelineConfig{Palette: "neon"}, false},
{"unknown density rejects", TimelineConfig{Density: "tiny"}, false},
{"unknown range rejects", TimelineConfig{RangePreset: "10y"}, false},
{"oversized range_from rejects", TimelineConfig{RangeFrom: string(make([]byte, 64))}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := RenderSpec{Shape: ShapeTimeline, Timeline: &tc.cfg}
err := s.Validate()
if tc.ok && err != nil {
t.Fatalf("expected ok, got error: %v", err)
}
if !tc.ok && !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
s := RenderSpec{Shape: "kanban"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {