@@ -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
112135type 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.
119144type 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).
134161func (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).
175203func (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 })
0 commit comments