feat(filter_spec): t-paliad-248 — symmetric date-range horizons

Slice A backend, fully additive. Adds six new TimeHorizon constants
to make the past/future fan symmetric for the date-range picker:

  next_1d, next_14d, next_all,
  past_1d, past_14d, past_all

Each one-sided 'all' is distinct from the existing HorizonAll
(bidirectional unbounded, Q26-gated) and HorizonAny (no time filter
at all). next_all keeps from=today + to=nil; past_all keeps to=tomorrow
+ from=nil — half-open intervals, never crossing the boundary.

computeViewSpecBounds gets twelve explicit fan arms plus the
pre-existing any/all/custom paths. validate() accepts the six new
horizons against any scope (none of them is the unbounded substrate
scan that triggers Q26 on HorizonAll).

New tests:
- TestFilterSpec_NewSymmetricHorizonsValidate — round-trip
- TestComputeViewSpecBounds_Horizons       — table of 14 cases
- TestComputeViewSpecBounds_NewHorizonsAreOneSided
- TestComputeViewSpecBounds_CustomRoundTrips
This commit is contained in:
mAi
2026-05-25 15:37:00 +02:00
parent 0f2f3e3ea1
commit 34e3d7188e
4 changed files with 176 additions and 8 deletions

View File

@@ -114,12 +114,18 @@ type TimeSpec struct {
type TimeHorizon string
const (
HorizonNext1d TimeHorizon = "next_1d"
HorizonNext7d TimeHorizon = "next_7d"
HorizonNext14d TimeHorizon = "next_14d"
HorizonNext30d TimeHorizon = "next_30d"
HorizonNext90d TimeHorizon = "next_90d"
HorizonNextAll TimeHorizon = "next_all"
HorizonPast1d TimeHorizon = "past_1d"
HorizonPast7d TimeHorizon = "past_7d"
HorizonPast14d TimeHorizon = "past_14d"
HorizonPast30d TimeHorizon = "past_30d"
HorizonPast90d TimeHorizon = "past_90d"
HorizonPastAll TimeHorizon = "past_all"
HorizonAny TimeHorizon = "any"
HorizonAll TimeHorizon = "all"
HorizonCustom TimeHorizon = "custom"
@@ -279,8 +285,9 @@ func (s *ScopeSpec) validate() error {
func (t *TimeSpec) validate(scope ScopeSpec) error {
switch t.Horizon {
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
case HorizonNext1d, HorizonNext7d, HorizonNext14d, HorizonNext30d, HorizonNext90d, HorizonNextAll,
HorizonPast1d, HorizonPast7d, HorizonPast14d, HorizonPast30d, HorizonPast90d, HorizonPastAll,
HorizonAny:
// fine
case HorizonAll:
// Q26: reject "all" unless scope.projects is explicit. Performance

View File

@@ -160,6 +160,23 @@ func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
}
}
// t-paliad-248: the symmetric date-range picker adds six new horizons —
// 1d/14d/all on each side. They must round-trip through validate without
// requiring scope.explicit (unlike HorizonAll which is a bidirectional-
// unbounded substrate scan and stays gated to ScopeExplicit per Q26).
func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
for _, h := range []TimeHorizon{
HorizonNext1d, HorizonNext14d, HorizonNextAll,
HorizonPast1d, HorizonPast14d, HorizonPastAll,
} {
s := validBaseSpec()
s.Time.Horizon = h
if err := s.Validate(); err != nil {
t.Fatalf("horizon %q must validate against a default scope: %v", h, err)
}
}
}
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline}

View File

@@ -156,11 +156,20 @@ type viewSpecBounds struct {
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
now = now.UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := day.AddDate(0, 0, 1)
switch ts.Horizon {
case HorizonNext1d:
from := day
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext7d:
from := day
to := day.AddDate(0, 0, 7)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext14d:
from := day
to := day.AddDate(0, 0, 14)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext30d:
from := day
to := day.AddDate(0, 0, 30)
@@ -169,18 +178,30 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
from := day
to := day.AddDate(0, 0, 90)
return viewSpecBounds{from: &from, to: &to}
case HorizonNextAll:
// One-sided unbounded — from today onwards, no upper bound.
// Distinct from HorizonAll (bidirectional unbounded) and
// HorizonAny (no time filter at all).
from := day
return viewSpecBounds{from: &from}
case HorizonPast1d:
from := day.AddDate(0, 0, -1)
return viewSpecBounds{from: &from, to: &tomorrow}
case HorizonPast7d:
from := day.AddDate(0, 0, -7)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
return viewSpecBounds{from: &from, to: &tomorrow}
case HorizonPast14d:
from := day.AddDate(0, 0, -14)
return viewSpecBounds{from: &from, to: &tomorrow}
case HorizonPast30d:
from := day.AddDate(0, 0, -30)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
return viewSpecBounds{from: &from, to: &tomorrow}
case HorizonPast90d:
from := day.AddDate(0, 0, -90)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
return viewSpecBounds{from: &from, to: &tomorrow}
case HorizonPastAll:
// One-sided unbounded — up to and including today, no lower bound.
return viewSpecBounds{to: &tomorrow}
case HorizonAny, HorizonAll:
return viewSpecBounds{}
case HorizonCustom:

View File

@@ -0,0 +1,123 @@
package services
// Pure tests for computeViewSpecBounds — t-paliad-248. Covers every
// TimeHorizon constant in the symmetric date-range fan, including the
// six new ones added when the picker shipped (next_1d / next_14d /
// next_all / past_1d / past_14d / past_all).
//
// Anchored against a fixed `now` so the assertions never drift with the
// wall clock. Each case asserts the bounds shape (open-ended vs.
// closed) and the exact offsets from the anchor day.
import (
"testing"
"time"
)
func TestComputeViewSpecBounds_Horizons(t *testing.T) {
// Anchor: 2026-05-25 14:37:00 UTC. computeViewSpecBounds normalises
// to startOfDay UTC, so the wall-clock time within the day is
// irrelevant.
now := time.Date(2026, 5, 25, 14, 37, 0, 0, time.UTC)
day := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
tomorrow := day.AddDate(0, 0, 1)
cases := []struct {
name string
horizon TimeHorizon
wantFrom *time.Time
wantTo *time.Time
}{
// Future fan.
{"next_1d", HorizonNext1d, &day, tptr(day.AddDate(0, 0, 1))},
{"next_7d", HorizonNext7d, &day, tptr(day.AddDate(0, 0, 7))},
{"next_14d", HorizonNext14d, &day, tptr(day.AddDate(0, 0, 14))},
{"next_30d", HorizonNext30d, &day, tptr(day.AddDate(0, 0, 30))},
{"next_90d", HorizonNext90d, &day, tptr(day.AddDate(0, 0, 90))},
// One-sided unbounded: from today, no upper bound.
{"next_all", HorizonNextAll, &day, nil},
// Past fan — upper bound is tomorrow (exclusive end-of-today).
{"past_1d", HorizonPast1d, tptr(day.AddDate(0, 0, -1)), &tomorrow},
{"past_7d", HorizonPast7d, tptr(day.AddDate(0, 0, -7)), &tomorrow},
{"past_14d", HorizonPast14d, tptr(day.AddDate(0, 0, -14)), &tomorrow},
{"past_30d", HorizonPast30d, tptr(day.AddDate(0, 0, -30)), &tomorrow},
{"past_90d", HorizonPast90d, tptr(day.AddDate(0, 0, -90)), &tomorrow},
// One-sided unbounded: no lower bound, up to and including today.
{"past_all", HorizonPastAll, nil, &tomorrow},
// Bidirectional unbounded — both nil.
{"any", HorizonAny, nil, nil},
{"all", HorizonAll, nil, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := computeViewSpecBounds(now, TimeSpec{Horizon: tc.horizon})
assertBound(t, "from", got.from, tc.wantFrom)
assertBound(t, "to", got.to, tc.wantTo)
})
}
}
// TestComputeViewSpecBounds_NewHorizonsAreOneSided documents the
// semantic distinction between next_all / past_all (one-sided
// unbounded, with one bound nil and the other set) and the existing
// HorizonAll / HorizonAny (both bounds nil).
func TestComputeViewSpecBounds_NewHorizonsAreOneSided(t *testing.T) {
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
nextAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonNextAll})
if nextAll.from == nil {
t.Fatalf("HorizonNextAll: from must be set (today), got nil")
}
if nextAll.to != nil {
t.Fatalf("HorizonNextAll: to must be nil (no upper bound), got %v", *nextAll.to)
}
pastAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonPastAll})
if pastAll.from != nil {
t.Fatalf("HorizonPastAll: from must be nil (no lower bound), got %v", *pastAll.from)
}
if pastAll.to == nil {
t.Fatalf("HorizonPastAll: to must be set (tomorrow), got nil")
}
any := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonAny})
if any.from != nil || any.to != nil {
t.Fatalf("HorizonAny: both bounds must be nil, got from=%v to=%v", any.from, any.to)
}
}
// TestComputeViewSpecBounds_CustomRoundTrips makes sure the custom
// horizon passes through the caller-supplied from/to verbatim — no
// normalisation, no clamping.
func TestComputeViewSpecBounds_CustomRoundTrips(t *testing.T) {
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
from := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
got := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonCustom, From: &from, To: &to})
if got.from == nil || !got.from.Equal(from) {
t.Fatalf("custom from: want %v, got %v", from, got.from)
}
if got.to == nil || !got.to.Equal(to) {
t.Fatalf("custom to: want %v, got %v", to, got.to)
}
}
func tptr(t time.Time) *time.Time { return &t }
func assertBound(t *testing.T, name string, got *time.Time, want *time.Time) {
t.Helper()
switch {
case got == nil && want == nil:
return
case got == nil:
t.Fatalf("%s: want %v, got nil", name, *want)
case want == nil:
t.Fatalf("%s: want nil, got %v", name, *got)
case !got.Equal(*want):
t.Fatalf("%s: want %v, got %v", name, *want, *got)
}
}