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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user