Skip to content

Commit f3ef14c

Browse files
atulmguptaCopilot
andcommitted
feat(timeline): load full state history and mute neon chart cyan
Remove the 90-day vehicle-states cap. Timeline All time / YTD send start/end (RFC3339) like fleet analytics; widgets still default to 7 days. Tone Drives-over-time and Time-by-State charging series to cyan-600. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 21fb4a2 commit f3ef14c

5 files changed

Lines changed: 130 additions & 97 deletions

File tree

internal/api/vehiclestates/handler.go

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"strconv"
2020
"time"
2121

22+
"github.com/ev-dev-labs/teslasync/internal/api/apiparams"
2223
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
2324
vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle"
2425

@@ -57,53 +58,75 @@ const (
5758
// useStateTimeline in useAdmin.ts passes days=7 explicitly when
5859
// callers don't override.
5960
vehicleStatesDefaultDays = 7
60-
// vehicleStatesMaxDays caps the window per Decision #4. A 90-day
61-
// window over fsm_transitions is bounded by the table's per-vehicle
62-
// row count (~1000s/year per the table doc on mig 000187 line 17),
63-
// so this cap keeps the SELECT cheap.
64-
vehicleStatesMaxDays = 90
6561
)
6662

67-
// parseVehicleStatesParams extracts and validates vehicle_id + days.
68-
// Returns ok=false after writing the appropriate 4xx response so the
69-
// caller can early-return.
70-
func (h *Handler) parseVehicleStatesParams(w http.ResponseWriter, r *http.Request) (vehicleID int64, days int, ok bool) {
63+
type vehicleStatesWindow struct {
64+
start time.Time
65+
end time.Time
66+
days int
67+
}
68+
69+
func (h *Handler) nowUTC() time.Time {
70+
if h.clock != nil {
71+
return h.clock()
72+
}
73+
return time.Now().UTC()
74+
}
75+
76+
// parseVehicleStatesParams extracts vehicle_id plus the query window.
77+
// start/end (RFC3339 or YYYY-MM-DD) take precedence over days, matching
78+
// /analytics/fleet. There is no max-days cap — fsm_transitions is
79+
// indexed and sparse (~thousands of rows/year). days still defaults to 7
80+
// when no range is given (widgets).
81+
func (h *Handler) parseVehicleStatesParams(w http.ResponseWriter, r *http.Request) (vehicleID int64, win vehicleStatesWindow, ok bool) {
7182
q := r.URL.Query()
7283

7384
vidStr := q.Get("vehicle_id")
7485
if vidStr == "" {
7586
httpx.WriteError(w, http.StatusBadRequest, "vehicle_id is required")
76-
return 0, 0, false
87+
return 0, vehicleStatesWindow{}, false
7788
}
7889
vid, err := strconv.ParseInt(vidStr, 10, 64)
7990
if err != nil || vid <= 0 {
8091
httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer")
81-
return 0, 0, false
92+
return 0, vehicleStatesWindow{}, false
93+
}
94+
95+
now := h.nowUTC()
96+
start, end, err := apiparams.ParseDateRangeValues(q.Get("start"), q.Get("end"))
97+
if err != nil {
98+
httpx.WriteError(w, http.StatusBadRequest, err.Error())
99+
return 0, vehicleStatesWindow{}, false
100+
}
101+
if !start.IsZero() || !end.IsZero() {
102+
if start.IsZero() {
103+
start = time.Unix(0, 0).UTC()
104+
}
105+
if end.IsZero() {
106+
end = now
107+
}
108+
days := int(end.Sub(start) / (24 * time.Hour))
109+
if days < 1 {
110+
days = 1
111+
}
112+
return vid, vehicleStatesWindow{start: start, end: end, days: days}, true
82113
}
83114

84-
days = vehicleStatesDefaultDays
115+
days := vehicleStatesDefaultDays
85116
if d := q.Get("days"); d != "" {
86117
v, err := strconv.Atoi(d)
87118
if err != nil {
88119
httpx.WriteError(w, http.StatusBadRequest, "days must be an integer")
89-
return 0, 0, false
120+
return 0, vehicleStatesWindow{}, false
90121
}
91122
if v < 1 {
92123
httpx.WriteError(w, http.StatusBadRequest, "days must be >= 1")
93-
return 0, 0, false
94-
}
95-
if v > vehicleStatesMaxDays {
96-
// Hand-write JSON to include the Decision #4 max field.
97-
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{
98-
"error": "days exceeds maximum",
99-
"max": vehicleStatesMaxDays,
100-
"code": httpx.HTTPStatusCode(http.StatusBadRequest),
101-
})
102-
return 0, 0, false
124+
return 0, vehicleStatesWindow{}, false
103125
}
104126
days = v
105127
}
106-
return vid, days, true
128+
winEnd, winStart := h.windowFor(days)
129+
return vid, vehicleStatesWindow{start: winStart, end: winEnd, days: days}, true
107130
}
108131

109132
// VehicleStatesTimelineResponse is the envelope returned by Timeline.
@@ -112,13 +135,17 @@ func (h *Handler) parseVehicleStatesParams(w http.ResponseWriter, r *http.Reques
112135
type VehicleStatesTimelineResponse struct {
113136
VehicleID int64 `json:"vehicle_id"`
114137
Days int `json:"days"`
138+
Start time.Time `json:"start"`
139+
End time.Time `json:"end"`
115140
Transitions []vehicledb.VehicleStateTransition `json:"transitions"`
116141
}
117142

118143
// VehicleStatesSummaryResponse is the envelope returned by Summary.
119144
type VehicleStatesSummaryResponse struct {
120145
VehicleID int64 `json:"vehicle_id"`
121146
Days int `json:"days"`
147+
Start time.Time `json:"start"`
148+
End time.Time `json:"end"`
122149
TotalSeconds float64 `json:"total_seconds"`
123150
ByState []vehicledb.VehicleStateSummaryRow `json:"by_state"`
124151
}
@@ -132,7 +159,7 @@ type VehicleStatesSummaryResponse struct {
132159
// an FK on fsm_transitions.vehicle_id (would-be dangling rows must not
133160
// resurrect a deleted vehicle).
134161
func (h *Handler) Timeline(w http.ResponseWriter, r *http.Request) {
135-
vehicleID, days, ok := h.parseVehicleStatesParams(w, r)
162+
vehicleID, win, ok := h.parseVehicleStatesParams(w, r)
136163
if !ok {
137164
return
138165
}
@@ -149,10 +176,9 @@ func (h *Handler) Timeline(w http.ResponseWriter, r *http.Request) {
149176
return
150177
}
151178

152-
end, start := h.windowFor(days)
153-
transitions, err := h.repo.Timeline(ctx, vehicleID, start, end)
179+
transitions, err := h.repo.Timeline(ctx, vehicleID, win.start, win.end)
154180
if err != nil {
155-
log.Error().Err(err).Int64("vehicle_id", vehicleID).Int("days", days).Msg("vehicle_states.timeline: query failed")
181+
log.Error().Err(err).Int64("vehicle_id", vehicleID).Int("days", win.days).Msg("vehicle_states.timeline: query failed")
156182
httpx.WriteError(w, http.StatusInternalServerError, "failed to load timeline")
157183
return
158184
}
@@ -162,7 +188,9 @@ func (h *Handler) Timeline(w http.ResponseWriter, r *http.Request) {
162188

163189
httpx.WriteJSON(w, http.StatusOK, VehicleStatesTimelineResponse{
164190
VehicleID: vehicleID,
165-
Days: days,
191+
Days: win.days,
192+
Start: win.start,
193+
End: win.end,
166194
Transitions: transitions,
167195
})
168196
}
@@ -173,7 +201,7 @@ func (h *Handler) Timeline(w http.ResponseWriter, r *http.Request) {
173201
// lives in database.computeStateSummary (purely Go, well-tested in the
174202
// repo unit tests).
175203
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
176-
vehicleID, days, ok := h.parseVehicleStatesParams(w, r)
204+
vehicleID, win, ok := h.parseVehicleStatesParams(w, r)
177205
if !ok {
178206
return
179207
}
@@ -190,10 +218,9 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
190218
return
191219
}
192220

193-
end, start := h.windowFor(days)
194-
rows, total, err := h.repo.Summary(ctx, vehicleID, start, end)
221+
rows, total, err := h.repo.Summary(ctx, vehicleID, win.start, win.end)
195222
if err != nil {
196-
log.Error().Err(err).Int64("vehicle_id", vehicleID).Int("days", days).Msg("vehicle_states.summary: query failed")
223+
log.Error().Err(err).Int64("vehicle_id", vehicleID).Int("days", win.days).Msg("vehicle_states.summary: query failed")
197224
httpx.WriteError(w, http.StatusInternalServerError, "failed to load summary")
198225
return
199226
}
@@ -203,7 +230,9 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
203230

204231
httpx.WriteJSON(w, http.StatusOK, VehicleStatesSummaryResponse{
205232
VehicleID: vehicleID,
206-
Days: days,
233+
Days: win.days,
234+
Start: win.start,
235+
End: win.end,
207236
TotalSeconds: total,
208237
ByState: rows,
209238
})

internal/api/vehiclestates/handler_test.go

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
//
1818
// Coverage map:
1919
// (a) Timeline ordering ASC -> TestVehicleStates_Timeline_OrderingASC
20-
// (b) Days clamp 7/30/90/91 -> 400 -> TestVehicleStates_Timeline_DaysClamp
20+
// (b) Days window 7/30/90/91 OK; start/end unbounded -> TestVehicleStates_Timeline_DaysClamp
2121
// (c) Summary % sum 100 ± 0.01 -> TestVehicleStates_Summary_PercentageSumsTo100
2222
// (d) Empty vehicle -> 200 -> TestVehicleStates_Timeline_EmptyVehicle_200
2323
// / TestVehicleStates_Summary_EmptyVehicle_200
@@ -114,16 +114,15 @@ func TestVehicleStates_Timeline_DaysClamp(t *testing.T) {
114114
wantStatus int
115115
wantDays int
116116
wantErrTxt string // substring match in body
117-
wantMax bool // requires max:90 payload
118117
}{
119-
{"default_when_absent", "vehicle_id=42", http.StatusOK, 7, "", false},
120-
{"days_7", "vehicle_id=42&days=7", http.StatusOK, 7, "", false},
121-
{"days_30", "vehicle_id=42&days=30", http.StatusOK, 30, "", false},
122-
{"days_90_max_inclusive", "vehicle_id=42&days=90", http.StatusOK, 90, "", false},
123-
{"days_91_exceeds_max", "vehicle_id=42&days=91", http.StatusBadRequest, 0, "days exceeds maximum", true},
124-
{"days_zero", "vehicle_id=42&days=0", http.StatusBadRequest, 0, "days must be", false},
125-
{"days_negative", "vehicle_id=42&days=-1", http.StatusBadRequest, 0, "days must be", false},
126-
{"days_non_integer", "vehicle_id=42&days=abc", http.StatusBadRequest, 0, "days must be an integer", false},
118+
{"default_when_absent", "vehicle_id=42", http.StatusOK, 7, ""},
119+
{"days_7", "vehicle_id=42&days=7", http.StatusOK, 7, ""},
120+
{"days_30", "vehicle_id=42&days=30", http.StatusOK, 30, ""},
121+
{"days_90", "vehicle_id=42&days=90", http.StatusOK, 90, ""},
122+
{"days_91_unbounded", "vehicle_id=42&days=91", http.StatusOK, 91, ""},
123+
{"days_zero", "vehicle_id=42&days=0", http.StatusBadRequest, 0, "days must be"},
124+
{"days_negative", "vehicle_id=42&days=-1", http.StatusBadRequest, 0, "days must be"},
125+
{"days_non_integer", "vehicle_id=42&days=abc", http.StatusBadRequest, 0, "days must be an integer"},
127126
}
128127

129128
for _, c := range cases {
@@ -144,16 +143,6 @@ func TestVehicleStates_Timeline_DaysClamp(t *testing.T) {
144143
if c.wantErrTxt != "" && !strings.Contains(rec.Body.String(), c.wantErrTxt) {
145144
t.Errorf("body missing %q\nbody=%s", c.wantErrTxt, rec.Body.String())
146145
}
147-
if c.wantMax {
148-
var body map[string]any
149-
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
150-
t.Fatalf("decode: %v", err)
151-
}
152-
maxV, ok := body["max"].(float64)
153-
if !ok || int(maxV) != 90 {
154-
t.Errorf("body.max = %v, want 90 (Decision #4 envelope)", body["max"])
155-
}
156-
}
157146
if c.wantStatus == http.StatusOK {
158147
var body VehicleStatesTimelineResponse
159148
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
@@ -179,6 +168,40 @@ func TestVehicleStates_Timeline_DaysClamp(t *testing.T) {
179168
}
180169
}
181170

171+
func TestVehicleStates_Timeline_ExplicitRange(t *testing.T) {
172+
t.Parallel()
173+
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
174+
repo := &fakeVehicleStatesRepo{
175+
exists: map[int64]bool{42: true},
176+
timeline: []vehicledb.VehicleStateTransition{},
177+
}
178+
h := newHandlerForTest(repo, now)
179+
rec := httptest.NewRecorder()
180+
h.Timeline(rec, vsRequest("/vehicle-states/timeline?vehicle_id=42&start=2015-01-01T00:00:00Z&end=2026-09-15T00:00:00Z&days=7"))
181+
if rec.Code != http.StatusOK {
182+
t.Fatalf("status = %d, want 200 (body=%s)", rec.Code, rec.Body.String())
183+
}
184+
if len(repo.gotTimelineCalls) != 1 {
185+
t.Fatalf("got %d timeline calls, want 1", len(repo.gotTimelineCalls))
186+
}
187+
call := repo.gotTimelineCalls[0]
188+
wantStart := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
189+
wantEnd := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC).Add(-time.Microsecond)
190+
if !call.start.Equal(wantStart) {
191+
t.Errorf("repo.start = %v, want %v", call.start, wantStart)
192+
}
193+
if !call.end.Equal(wantEnd) {
194+
t.Errorf("repo.end = %v, want %v", call.end, wantEnd)
195+
}
196+
var body VehicleStatesTimelineResponse
197+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
198+
t.Fatalf("decode: %v", err)
199+
}
200+
if body.Days < 4000 {
201+
t.Errorf("body.days = %d, want full-history span (>=4000)", body.Days)
202+
}
203+
}
204+
182205
// Same clamp behavior must apply to /summary — re-run the boundary
183206
// cases via a focused sub-test rather than duplicating the full table.
184207
func TestVehicleStates_Summary_DaysClamp(t *testing.T) {
@@ -191,8 +214,8 @@ func TestVehicleStates_Summary_DaysClamp(t *testing.T) {
191214
wantStatus int
192215
}{
193216
{"default", "vehicle_id=42", http.StatusOK},
194-
{"max_inclusive", "vehicle_id=42&days=90", http.StatusOK},
195-
{"exceeds_max", "vehicle_id=42&days=91", http.StatusBadRequest},
217+
{"days_90", "vehicle_id=42&days=90", http.StatusOK},
218+
{"days_91_unbounded", "vehicle_id=42&days=91", http.StatusOK},
196219
{"zero", "vehicle_id=42&days=0", http.StatusBadRequest},
197220
}
198221
for _, c := range cases {

web/src/features/analytics/pages/TimelinePage.test.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -383,15 +383,13 @@ describe('TimelinePage', () => {
383383
expect(screen.queryByRole('dialog', { name: 'asleep → driving' })).toBeNull()
384384
})
385385

386-
it('clamps All time / wide ranges to 90 days instead of failing the API', async () => {
386+
it('loads All time / wide ranges via start/end instead of a days cap', async () => {
387387
renderPage('/timeline?from=2015-01-01&to=2026-09-15')
388388

389-
expect(
390-
await screen.findByText(
391-
'State history is limited to the last 90 days. All time and year-to-date still load that window instead of failing.',
392-
),
393-
).toBeInTheDocument()
394389
expect(screen.queryByText(/days exceeds maximum/i)).toBeNull()
390+
expect(
391+
screen.queryByText(/State history is limited to the last 90 days/i),
392+
).toBeNull()
395393

396394
await waitFor(() => {
397395
const timelineCalls = mockedRequest.mock.calls
@@ -400,9 +398,12 @@ describe('TimelinePage', () => {
400398
const summaryCalls = mockedRequest.mock.calls
401399
.map((c) => String(c[0]))
402400
.filter((p) => p.startsWith('/vehicle-states/summary'))
403-
expect(timelineCalls.some((p) => p.includes('days=90'))).toBe(true)
404-
expect(summaryCalls.some((p) => p.includes('days=90'))).toBe(true)
405-
expect(timelineCalls.every((p) => !/days=\d{3,}/.test(p))).toBe(true)
401+
expect(timelineCalls.length).toBeGreaterThan(0)
402+
expect(summaryCalls.length).toBeGreaterThan(0)
403+
expect(timelineCalls.every((p) => p.includes('start=') && p.includes('end='))).toBe(true)
404+
expect(summaryCalls.every((p) => p.includes('start=') && p.includes('end='))).toBe(true)
405+
expect(timelineCalls.every((p) => !p.includes('days='))).toBe(true)
406+
expect(timelineCalls.some((p) => decodeURIComponent(p).includes('2015-01-01'))).toBe(true)
406407
})
407408
})
408409
})

0 commit comments

Comments
 (0)