diff --git a/internal/api/journey/arrival.go b/internal/api/journey/arrival.go new file mode 100644 index 000000000..590df2618 --- /dev/null +++ b/internal/api/journey/arrival.go @@ -0,0 +1,263 @@ +package journey + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Pace bounds in SI m/s. Below stopMS the car reads as parked (no +// ETA — a stopped car has none); above implausibleMS the fix pair is +// a GPS jump, not motion (~360 km/h). +const ( + stopMS = 2.0 + implausibleMS = 100.0 +) + +// Arrival is the GET response: ETA from recent pace plus the charge +// advice for making the destination with buffer. LeftM stays the +// straight-line remainder; ETA and advice apply the learned route +// factor when history exists. +type Arrival struct { + SessionID int64 `json:"session_id"` + DestName string `json:"dest_name"` + LeftM *float64 `json:"left_m"` + PaceMS *float64 `json:"pace_ms"` + EtaAt *time.Time `json:"eta_at"` + Moving bool `json:"moving"` + Verdict string `json:"verdict"` // ok, attention, action, unknown + ShortfallWh *float64 `json:"shortfall_wh"` + RouteFactor *float64 `json:"route_factor"` + RouteTrips int `json:"route_trips"` + Evidence []string `json:"evidence"` +} + +// PaceMS derives speed from two fixes. Odometer delta wins when both +// fixes carry one (road truth beats coordinates); otherwise the +// haversine gap over elapsed time. Zero/negative time, odometer +// rollback, and implausible speeds all read as no pace. Pure. +func PaceMS(older, newer *Checkpoint) (float64, bool) { + if older == nil || newer == nil { + return 0, false + } + dt := newer.RecordedAt.Sub(older.RecordedAt).Seconds() + if dt <= 0 { + return 0, false + } + dist := haversineM(older.Lat, older.Lng, newer.Lat, newer.Lng) + if older.OdometerM != nil && newer.OdometerM != nil { + if *newer.OdometerM < *older.OdometerM { + return 0, false + } + dist = *newer.OdometerM - *older.OdometerM + } + pace := dist / dt + if pace < 0 || pace > implausibleMS { + return 0, false + } + return pace, true +} + +// ArrivalETA projects arrival from distance left and pace. A stopped +// car (below stopMS) has no ETA — moving=false, eta nil. Pure. +func ArrivalETA(leftM, paceMS float64, now time.Time) (eta *time.Time, moving bool) { + if paceMS < stopMS || leftM <= 0 { + return nil, paceMS >= stopMS + } + t := now.Add(time.Duration(leftM / paceMS * float64(time.Second))) + return &t, true +} + +// ChargeAdvice grades destination energy (via ComputeRange, the single +// verdict source) and sizes the top-up that restores the buffer when +// short. Shortfall is nil when energy already covers need×buffer or +// when inputs are missing. Pure. +func ChargeAdvice(haveWh *float64, leftM float64, effWhKm *float64) (verdict string, shortfallWh *float64) { + r := ComputeRange(haveWh, leftM, effWhKm) + if r.Verdict == ItemOK || r.NeedWh == nil || haveWh == nil { + return r.Verdict, nil + } + short := *r.NeedWh*rangeBuffer - *haveWh + if short <= 0 { + return r.Verdict, nil + } + return r.Verdict, &short +} + +// ArrivalHandler serves arrival prep. Stateless beyond constructor +// inputs; safe for concurrent use. +type ArrivalHandler struct { + store SessionStore + trail TrailStore + live LiveSignals + now func() time.Time +} + +// NewArrivalHandler wires the handler. Panics on nil inputs (fail-fast +// wiring contract, matching sibling handlers). +func NewArrivalHandler(store SessionStore, trail TrailStore, live LiveSignals) *ArrivalHandler { + if store == nil || trail == nil || live == nil { + panic("journey: nil dependency") + } + return &ArrivalHandler{store: store, trail: trail, live: live, now: time.Now} +} + +// Prep serves GET /journey/sessions/{id}/arrival: ETA from recent pace +// plus the charge advice for the destination. Degrades honestly — no +// fixes, no pace, or no energy each narrow the answer instead of +// failing it. +func (h *ArrivalHandler) Prep(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + now := h.now().UTC() + latest, err := h.trail.LatestCheckpoint(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: latest checkpoint failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + progress := ComputeProgress(session.OriginLat, session.OriginLng, session.DestLat, session.DestLng, latest) + pace, paceOK, err := h.pace(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: trail failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + var left *float64 + if progress != nil { + left = &progress.LeftM + } + factor, trips, err := routeFactorFor(ctx, h.trail, session) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: route history failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read route history") + return + } + effLeft := left + if left != nil && factor != nil { + adjusted := *left * *factor + effLeft = &adjusted + } + var eta *time.Time + moving := false + if effLeft != nil && paceOK { + eta, moving = ArrivalETA(*effLeft, pace, now) + } else if paceOK { + _, moving = ArrivalETA(1, pace, now) + } + verdict, shortfall, err := h.advice(ctx, session, effLeft) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: arrival advice failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read vehicle state") + return + } + out := Arrival{ + SessionID: id, DestName: session.DestName, LeftM: left, + EtaAt: eta, Moving: moving, Verdict: verdict, ShortfallWh: shortfall, + RouteFactor: factor, RouteTrips: trips, + } + if paceOK { + out.PaceMS = &pace + } + out.Evidence = arrivalEvidence(session, left, out.PaceMS, eta, moving, verdict, shortfall, factor, trips) + httpx.WriteJSON(w, http.StatusOK, out) +} + +// pace orders the two newest fixes by time (stores order either way) +// and derives speed. False when fewer than two fixes exist. +func (h *ArrivalHandler) pace(ctx context.Context, sessionID int64) (float64, bool, error) { + pair, err := h.trail.Trail(ctx, sessionID, 2) + if err != nil { + return 0, false, err + } + if len(pair) < 2 { + return 0, false, nil + } + older, newer := pair[0], pair[1] + if newer.RecordedAt.Before(older.RecordedAt) { + older, newer = newer, older + } + pace, ok := PaceMS(older, newer) + return pace, ok, nil +} + +func (h *ArrivalHandler) advice(ctx context.Context, session *Session, left *float64) (string, *float64, error) { + if left == nil { + return ItemUnknown, nil, nil + } + energyKWh, err := signalFloat(h.live, ctx, session.VehicleID, "EnergyRemaining") + if err != nil { + return "", nil, err + } + var haveWh *float64 + if energyKWh != nil { + have := *energyKWh * 1000 + haveWh = &have + } + eff, ok, err := h.trail.VehicleEfficiency(ctx, session.VehicleID) + if err != nil { + return "", nil, err + } + var effPtr *float64 + if ok { + effPtr = &eff + } + verdict, shortfall := ChargeAdvice(haveWh, *left, effPtr) + return verdict, shortfall, nil +} + +func arrivalEvidence(session *Session, left, pace *float64, eta *time.Time, moving bool, verdict string, shortfall *float64, factor *float64, trips int) []string { + out := []string{} + dest := session.DestName + if dest == "" { + dest = "the destination" + } + if left == nil { + out = append(out, "route coordinates missing — distance to "+dest+" unavailable") + } else { + out = append(out, formatKm("", *left/1000)+" to "+dest) + } + if factor != nil { + out = append(out, "adjusted by your "+strconv.FormatFloat(*factor, 'f', 2, 64)+"× history on this route ("+strconv.Itoa(trips)+" trips)") + } + switch { + case pace == nil: + out = append(out, "need two fixes to read pace") + case !moving: + out = append(out, "parked — ETA once moving") + case eta != nil: + out = append(out, "ETA "+eta.Format("15:04")) + } + switch verdict { + case ItemOK: + out = append(out, "arrive with buffer") + case ItemAttention, ItemAction: + if shortfall != nil { + out = append(out, "top up ≈ "+strconv.FormatFloat(*shortfall/1000, 'f', 1, 64)+" kWh en route to hold the buffer") + } else { + out = append(out, "arrival energy tight — charge soon") + } + default: + out = append(out, "arrival energy unknown: needs live energy + drive history") + } + return out +} diff --git a/internal/api/journey/arrival_test.go b/internal/api/journey/arrival_test.go new file mode 100644 index 000000000..c3c5c87e0 --- /dev/null +++ b/internal/api/journey/arrival_test.go @@ -0,0 +1,231 @@ +package journey + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +func TestPaceMS(t *testing.T) { + base := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) + // Odometer wins: 90 km in one hour. + older := &Checkpoint{RecordedAt: base, Lat: 39.7, Lng: -105.0, OdometerM: fptr(100000)} + newer := &Checkpoint{RecordedAt: base.Add(time.Hour), Lat: 39.5, Lng: -104.0, OdometerM: fptr(190000)} + if pace, ok := PaceMS(older, newer); !ok || pace < 24.99 || pace > 25.01 { + t.Fatalf("odometer pace = %f %v, want 25", pace, ok) + } + // Coordinates when the odometer is absent: 0.01° lat ≈ 1112 m. + a := &Checkpoint{RecordedAt: base, Lat: 39.7, Lng: -105.0} + b := &Checkpoint{RecordedAt: base.Add(100 * time.Second), Lat: 39.71, Lng: -105.0} + if pace, ok := PaceMS(a, b); !ok || pace < 11 || pace > 11.3 { + t.Fatalf("coordinate pace = %f %v, want ~11.1", pace, ok) + } + same := &Checkpoint{RecordedAt: base, Lat: 39.7, Lng: -105.0, OdometerM: fptr(100000)} + rollback := &Checkpoint{RecordedAt: base.Add(time.Minute), Lat: 39.7, Lng: -105.0, OdometerM: fptr(99999)} + jump := &Checkpoint{RecordedAt: base.Add(time.Second), Lat: 30.0, Lng: -90.0} + for name, tc := range map[string][2]*Checkpoint{ + "nil older": {nil, newer}, + "nil newer": {older, nil}, + "zero dt": {older, &Checkpoint{RecordedAt: base, Lat: 39.5, Lng: -104.0}}, + "negative dt": {newer, older}, + "rollback": {same, rollback}, + "gps jump": {a, jump}, + } { + if pace, ok := PaceMS(tc[0], tc[1]); ok { + t.Fatalf("%s: pace = %f, want reject", name, pace) + } + } +} + +func TestArrivalETA(t *testing.T) { + now := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + eta, moving := ArrivalETA(36000, 20, now) + if !moving || eta == nil || !eta.Equal(now.Add(30*time.Minute)) { + t.Fatalf("eta = %v %v, want 10:30 moving", eta, moving) + } + if eta, moving := ArrivalETA(36000, 1.5, now); moving || eta != nil { + t.Fatalf("parked = %v %v, want nil/false", eta, moving) + } + if eta, moving := ArrivalETA(0, 20, now); !moving || eta != nil { + t.Fatalf("arrived = %v %v, want nil/true", eta, moving) + } +} + +func TestChargeAdvice(t *testing.T) { + verdict, short := ChargeAdvice(fptr(30000), 100_000, fptr(180)) + if verdict != ItemOK || short != nil { + t.Fatalf("ok = %s %v, want ok/nil", verdict, short) + } + verdict, short = ChargeAdvice(fptr(19000), 100_000, fptr(180)) + if verdict != ItemAttention || short == nil { + t.Fatalf("attention = %s %v", verdict, short) + } + // 18000×1.15 − 19000 = 1700. + if *short < 1699 || *short > 1701 { + t.Fatalf("shortfall = %f, want 1700", *short) + } + verdict, short = ChargeAdvice(fptr(5000), 100_000, fptr(180)) + if verdict != ItemAction || short == nil || *short < 15699 || *short > 15701 { + t.Fatalf("action = %s %v, want action/15700", verdict, short) + } + if verdict, short := ChargeAdvice(nil, 100_000, fptr(180)); verdict != ItemUnknown || short != nil { + t.Fatalf("unknown = %s %v", verdict, short) + } +} + +func arrivalSetup() (*fakeStore, *fakeTrail) { + f := newFakeStore() + s := liveSession() + s.DestName = "KC" + f.sessions[1] = s + tr := &fakeTrail{eff: 180, hasEff: true, points: []*Checkpoint{ + {ID: 1, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC), + Lat: 39.7392, Lng: -104.9903, OdometerM: fptr(100000)}, + {ID: 2, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC), + Lat: 39.5, Lng: -100.0, SocPct: fptr(71), OdometerM: fptr(190000)}, + }} + return f, tr +} + +func TestPrep(t *testing.T) { + f, tr := arrivalSetup() + h := NewArrivalHandler(f, tr, &fakeLive{values: map[string]signal.SignalValue{"EnergyRemaining": 60.0}}) + now := time.Date(2026, 9, 14, 10, 5, 0, 0, time.UTC) + h.now = func() time.Time { return now } + rec := httptest.NewRecorder() + h.Prep(rec, liveRequest(http.MethodGet, "/journey/sessions/1/arrival", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Arrival + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SessionID != 1 || got.DestName != "KC" { + t.Fatalf("arrival = %+v", got) + } + if got.LeftM == nil || *got.LeftM <= 0 { + t.Fatalf("left = %v", got.LeftM) + } + if got.PaceMS == nil || *got.PaceMS < 24.99 || *got.PaceMS > 25.01 { + t.Fatalf("pace = %v, want 25", got.PaceMS) + } + if !got.Moving || got.EtaAt == nil { + t.Fatalf("moving = %v eta = %v", got.Moving, got.EtaAt) + } + wantETA := now.Add(time.Duration(*got.LeftM / 25 * float64(time.Second))) + if got.EtaAt.Sub(wantETA) > time.Minute || wantETA.Sub(*got.EtaAt) > time.Minute { + t.Fatalf("eta = %v, want ~%v", got.EtaAt, wantETA) + } + // 60 kWh against ~500 km at 180 Wh/km: action with a shortfall. + if got.Verdict != ItemAction || got.ShortfallWh == nil || *got.ShortfallWh <= 0 { + t.Fatalf("advice = %s %v, want action/shortfall", got.Verdict, got.ShortfallWh) + } + if len(got.Evidence) != 3 { + t.Fatalf("evidence = %v, want 3 lines", got.Evidence) + } +} + +func TestPrepRouteFactor(t *testing.T) { + f, tr := arrivalSetup() + s := f.sessions[1] + s.OriginName, s.DestName = "Denver", "KC" + tr.legs = []RouteLeg{ + {DistanceM: 990000, StraightM: 900000}, + {DistanceM: 900000, StraightM: 900000}, + } + h := NewArrivalHandler(f, tr, &fakeLive{values: map[string]signal.SignalValue{"EnergyRemaining": 200.0}}) + now := time.Date(2026, 9, 14, 10, 5, 0, 0, time.UTC) + h.now = func() time.Time { return now } + rec := httptest.NewRecorder() + h.Prep(rec, liveRequest(http.MethodGet, "/journey/sessions/1/arrival", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Arrival + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.RouteFactor == nil || *got.RouteFactor < 1.049 || *got.RouteFactor > 1.051 { + t.Fatalf("factor = %v, want 1.05", got.RouteFactor) + } + if got.RouteTrips != 2 { + t.Fatalf("trips = %d, want 2", got.RouteTrips) + } + // ETA runs on the adjusted remainder: left×1.05 at 25 m/s. + wantETA := now.Add(time.Duration(*got.LeftM * 1.05 / 25 * float64(time.Second))) + if got.EtaAt.Sub(wantETA) > time.Minute || wantETA.Sub(*got.EtaAt) > time.Minute { + t.Fatalf("eta = %v, want ~%v", got.EtaAt, wantETA) + } + if len(got.Evidence) != 4 { + t.Fatalf("evidence = %v, want 4 lines", got.Evidence) + } +} + +func TestPrepParked(t *testing.T) { + f, tr := arrivalSetup() + tr.points[1].OdometerM = fptr(100000) // same odometer: pace 0. + h := NewArrivalHandler(f, tr, &fakeLive{}) + rec := httptest.NewRecorder() + h.Prep(rec, liveRequest(http.MethodGet, "/journey/sessions/1/arrival", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Arrival + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Moving || got.EtaAt != nil { + t.Fatalf("parked = moving:%v eta:%v", got.Moving, got.EtaAt) + } + if got.PaceMS == nil || *got.PaceMS != 0 { + t.Fatalf("pace = %v, want 0", got.PaceMS) + } + if got.Verdict != ItemUnknown { + t.Fatalf("verdict = %s, want unknown", got.Verdict) + } +} + +func TestPrepDegraded(t *testing.T) { + f := newFakeStore() + f.sessions[1] = liveSession() + h := NewArrivalHandler(f, &fakeTrail{}, &fakeLive{}) + rec := httptest.NewRecorder() + h.Prep(rec, liveRequest(http.MethodGet, "/journey/sessions/1/arrival", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Arrival + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + // No fix: left falls back to the whole leg, pace is absent. + if got.LeftM == nil || *got.LeftM <= 0 { + t.Fatalf("left = %v, want full leg", got.LeftM) + } + if got.PaceMS != nil || got.Moving || got.EtaAt != nil { + t.Fatalf("pace = %v moving = %v eta = %v", got.PaceMS, got.Moving, got.EtaAt) + } + if len(got.Evidence) != 3 { + t.Fatalf("evidence = %v, want 3 lines", got.Evidence) + } +} + +func TestPrepErrors(t *testing.T) { + f, tr := arrivalSetup() + h := NewArrivalHandler(f, tr, &fakeLive{}) + rec := httptest.NewRecorder() + h.Prep(rec, liveRequest(http.MethodGet, "/journey/sessions/9/arrival", "9", "")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } + rec = httptest.NewRecorder() + h.Prep(rec, liveRequest(http.MethodGet, "/journey/sessions/abc/arrival", "abc", "")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } +} diff --git a/internal/api/journey/checklist.go b/internal/api/journey/checklist.go new file mode 100644 index 000000000..f55e4d5fa --- /dev/null +++ b/internal/api/journey/checklist.go @@ -0,0 +1,320 @@ +package journey + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/api/stormguard" + systemdb "github.com/ev-dev-labs/teslasync/internal/database/system" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +// Checklist item statuses. +const ( + ItemOK = "ok" + ItemAttention = "attention" + ItemAction = "action" + ItemUnknown = "unknown" +) + +// Checklist item keys. +const ( + KeyChargeLevel = "charge_level" + KeyChargeLimit = "charge_limit" + KeyTirePressure = "tire_pressure" + KeyStorm = "storm" + KeySoftwareUpdate = "software_update" +) + +// Evaluation thresholds. Charge targets follow the trip-ready +// convention (leave at 80%+ with headroom to charge there); tire +// pressures follow the Tesla placard of 42 PSI ≈ 2.9 bar. +const ( + tripReadySoc = 80.0 + tripLowSoc = 60.0 + tripReadyLimit = 85.0 + tripLowLimit = 80.0 + placardBar = 2.9 + lowTireBar = 2.7 + stormRecency = 6 * time.Hour + tireCorners = 4 +) + +// Item is one evaluated checklist row. +type Item struct { + Key string `json:"key"` + Status string `json:"status"` + Detail string `json:"detail"` +} + +// Inputs are the evaluated signals. Nil means never reported — the +// item degrades to unknown rather than guessing. +type Inputs struct { + Soc *float64 + Limit *float64 + TiresBar [tireCorners]*float64 // FL, FR, RL, RR in bar + Storm *stormguard.Event + Update *vehiclemodel.SoftwareUpdate +} + +// EvaluateChecklist grades trip readiness. Pure: no I/O, +// deterministic. Always returns all five items in key order. +func EvaluateChecklist(in Inputs, now time.Time) []Item { + return []Item{ + chargeLevelItem(in.Soc), + chargeLimitItem(in.Limit), + tireItem(in.TiresBar), + stormItem(in.Storm, now), + updateItem(in.Update), + } +} + +func chargeLevelItem(soc *float64) Item { + if soc == nil { + return Item{KeyChargeLevel, ItemUnknown, "battery level never reported — wake the vehicle"} + } + detail := fmt.Sprintf("%.0f%% (trip-ready is %.0f%%+)", *soc, tripReadySoc) + switch { + case *soc >= tripReadySoc: + return Item{KeyChargeLevel, ItemOK, detail} + case *soc >= tripLowSoc: + return Item{KeyChargeLevel, ItemAttention, detail} + default: + return Item{KeyChargeLevel, ItemAction, detail} + } +} + +func chargeLimitItem(limit *float64) Item { + if limit == nil { + return Item{KeyChargeLimit, ItemUnknown, "charge limit never reported — wake the vehicle"} + } + detail := fmt.Sprintf("limit %.0f%% (raise to %.0f%%+ for trips)", *limit, tripReadyLimit) + switch { + case *limit >= tripReadyLimit: + return Item{KeyChargeLimit, ItemOK, detail} + case *limit >= tripLowLimit: + return Item{KeyChargeLimit, ItemAttention, detail} + default: + return Item{KeyChargeLimit, ItemAction, detail} + } +} + +func tireItem(tires [tireCorners]*float64) Item { + names := [tireCorners]string{"FL", "FR", "RL", "RR"} + low, lowName := 0.0, "" + seen := 0 + for i, t := range tires { + if t == nil { + continue + } + seen++ + if lowName == "" || *t < low { + low, lowName = *t, names[i] + } + } + if seen == 0 { + return Item{KeyTirePressure, ItemUnknown, "no tire reports — drive to wake the sensors"} + } + detail := fmt.Sprintf("lowest %s at %.1f bar (placard %.1f)", lowName, low, placardBar) + switch { + case low >= placardBar: + return Item{KeyTirePressure, ItemOK, detail} + case low >= lowTireBar: + return Item{KeyTirePressure, ItemAttention, detail} + default: + return Item{KeyTirePressure, ItemAction, detail} + } +} + +func stormItem(ev *stormguard.Event, now time.Time) Item { + if ev == nil || ev.Level == stormguard.LevelNone || now.Sub(ev.CreatedAt) > stormRecency { + return Item{KeyStorm, ItemOK, "no severe weather on record"} + } + detail := fmt.Sprintf("%s %s ago: %s", ev.Level, ago(now.Sub(ev.CreatedAt)), ev.Reason) + if ev.Level == stormguard.LevelWarning { + return Item{KeyStorm, ItemAction, detail} + } + return Item{KeyStorm, ItemAttention, detail} +} + +func updateItem(u *vehiclemodel.SoftwareUpdate) Item { + if u == nil { + return Item{KeySoftwareUpdate, ItemOK, "no update on record"} + } + switch u.Status { + case "installing", "downloading": + return Item{KeySoftwareUpdate, ItemAction, fmt.Sprintf("%s %s in progress — may block departure", u.Version, u.Status)} + case "available": + if u.ScheduledAt != nil { + return Item{KeySoftwareUpdate, ItemAttention, fmt.Sprintf("%s scheduled for %s", u.Version, u.ScheduledAt.Format("Mon 15:04"))} + } + return Item{KeySoftwareUpdate, ItemAttention, fmt.Sprintf("%s available — install after the trip", u.Version)} + default: // installed and anything else + return Item{KeySoftwareUpdate, ItemOK, fmt.Sprintf("%s %s", u.Version, u.Status)} + } +} + +func ago(d time.Duration) string { + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + return fmt.Sprintf("%dh", int(d.Hours())) +} + +// Run is one persisted checklist evaluation. +type Run struct { + ID int64 `json:"id"` + SessionID int64 `json:"session_id"` + RunAt time.Time `json:"run_at"` + Items []Item `json:"items"` +} + +// StormEvents is the storm-history port. *stormguard.Store satisfies it. +type StormEvents interface { + ListEvents(ctx context.Context, vehicleID int64, limit int) ([]*stormguard.Event, error) +} + +// UpdateHistory is the software-update port. +// *systemdb.SoftwareUpdateRepo satisfies it. +type UpdateHistory interface { + GetByVehicle(ctx context.Context, vehicleID int64, limit int, start, end time.Time) ([]*vehiclemodel.SoftwareUpdate, error) +} + +// RunStore persists checklist runs. *Store satisfies it. +type RunStore interface { + SaveChecklistRun(ctx context.Context, sessionID int64, items []Item) (*Run, error) + LatestChecklistRun(ctx context.Context, sessionID int64) (*Run, error) +} + +// ChecklistHandler serves the ready-to-roll checklist. Stateless +// beyond constructor inputs; safe for concurrent use. +type ChecklistHandler struct { + store SessionStore + runs RunStore + live LiveSignals + storm StormEvents + updates UpdateHistory + now func() time.Time +} + +// NewChecklistHandler wires the handler. Panics on nil inputs +// (fail-fast wiring contract, matching sibling handlers). +func NewChecklistHandler(store SessionStore, runs RunStore, live LiveSignals, storm StormEvents, updates UpdateHistory) *ChecklistHandler { + if store == nil || runs == nil || live == nil || storm == nil || updates == nil { + panic("journey: nil dependency") + } + return &ChecklistHandler{store: store, runs: runs, live: live, storm: storm, updates: updates, now: time.Now} +} + +// Refresh serves POST /journey/sessions/{id}/checklist/runs: evaluate +// live readiness and persist the run. +func (h *ChecklistHandler) Refresh(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + now := h.now().UTC() + inputs, err := h.gather(ctx, session.VehicleID, now) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: checklist gather failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read vehicle state") + return + } + run, err := h.runs.SaveChecklistRun(ctx, id, EvaluateChecklist(inputs, now)) + if err != nil { + if errors.Is(err, ErrNoSession) { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + log.Error().Err(err).Int64("id", id).Msg("journey: checklist save failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save checklist") + return + } + httpx.WriteJSON(w, http.StatusCreated, run) +} + +// Latest serves GET /journey/sessions/{id}/checklist: the most recent +// run, or 404 when the checklist never ran. +func (h *ChecklistHandler) Latest(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + run, err := h.runs.LatestChecklistRun(r.Context(), id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: checklist read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read checklist") + return + } + if run == nil { + httpx.WriteError(w, http.StatusNotFound, "checklist never ran for this journey") + return + } + httpx.WriteJSON(w, http.StatusOK, run) +} + +var tireSignals = [tireCorners]string{ + "TpmsPressureFl", "TpmsPressureFr", "TpmsPressureRl", "TpmsPressureRr", +} + +func (h *ChecklistHandler) gather(ctx context.Context, vehicleID int64, now time.Time) (Inputs, error) { + var in Inputs + var err error + if in.Soc, err = signalFloat(h.live, ctx, vehicleID, "Soc"); err != nil { + return in, err + } + if in.Limit, err = signalFloat(h.live, ctx, vehicleID, "ChargeLimitSoc"); err != nil { + return in, err + } + for i, name := range tireSignals { + if in.TiresBar[i], err = signalFloat(h.live, ctx, vehicleID, name); err != nil { + return in, err + } + } + events, err := h.storm.ListEvents(ctx, vehicleID, 5) + if err != nil { + return in, err + } + for _, ev := range events { + if ev != nil && ev.Level != stormguard.LevelNone { + in.Storm = ev + break + } + } + updates, err := h.updates.GetByVehicle(ctx, vehicleID, 1, time.Time{}, now) + if err != nil { + return in, err + } + if len(updates) > 0 { + in.Update = updates[0] + } + return in, nil +} + +// Compile-time port assertions. +var ( + _ StormEvents = (*stormguard.Store)(nil) + _ LiveSignals = (signal.LiveStateReader)(nil) + _ Meteo = (*stormguard.Client)(nil) + _ UpdateHistory = (*systemdb.SoftwareUpdateRepo)(nil) +) diff --git a/internal/api/journey/checklist_test.go b/internal/api/journey/checklist_test.go new file mode 100644 index 000000000..12ab2915e --- /dev/null +++ b/internal/api/journey/checklist_test.go @@ -0,0 +1,267 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/ev-dev-labs/teslasync/internal/api/stormguard" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +func TestEvaluateChecklistAllOK(t *testing.T) { + now := time.Now().UTC() + items := EvaluateChecklist(Inputs{ + Soc: fptr(85), + Limit: fptr(90), + TiresBar: [4]*float64{fptr(2.9), fptr(3.0), fptr(2.95), fptr(3.0)}, + }, now) + if len(items) != 5 { + t.Fatalf("items = %d, want 5", len(items)) + } + for _, it := range items { + if it.Status != ItemOK { + t.Fatalf("%s = %q (%s), want ok", it.Key, it.Status, it.Detail) + } + } +} + +func TestEvaluateChecklistGrades(t *testing.T) { + now := time.Now().UTC() + stormAt := now.Add(-2 * time.Hour) + items := EvaluateChecklist(Inputs{ + Soc: fptr(70), + Limit: fptr(75), + TiresBar: [4]*float64{fptr(2.9), fptr(2.6), fptr(2.9), fptr(2.9)}, + Storm: &stormguard.Event{ + Level: stormguard.LevelWarning, Reason: "thunder", + CreatedAt: stormAt, + }, + Update: &vehiclemodel.SoftwareUpdate{Version: "2026.8", Status: "available"}, + }, now) + got := map[string]string{} + for _, it := range items { + got[it.Key] = it.Status + } + want := map[string]string{ + KeyChargeLevel: ItemAttention, + KeyChargeLimit: ItemAction, + KeyTirePressure: ItemAction, + KeyStorm: ItemAction, + KeySoftwareUpdate: ItemAttention, + } + for k, w := range want { + if got[k] != w { + t.Fatalf("%s = %q, want %q", k, got[k], w) + } + } +} + +func TestEvaluateChecklistUnknowns(t *testing.T) { + items := EvaluateChecklist(Inputs{}, time.Now().UTC()) + got := map[string]string{} + for _, it := range items { + got[it.Key] = it.Status + } + // Missing signals degrade; absent storm/update records are fine. + if got[KeyChargeLevel] != ItemUnknown || got[KeyChargeLimit] != ItemUnknown || got[KeyTirePressure] != ItemUnknown { + t.Fatalf("signal items = %v, want unknown", got) + } + if got[KeyStorm] != ItemOK || got[KeySoftwareUpdate] != ItemOK { + t.Fatalf("record items = %v, want ok", got) + } +} + +func TestEvaluateChecklistStaleStorm(t *testing.T) { + now := time.Now().UTC() + items := EvaluateChecklist(Inputs{ + Storm: &stormguard.Event{ + Level: stormguard.LevelWarning, Reason: "old", + CreatedAt: now.Add(-24 * time.Hour), + }, + }, now) + for _, it := range items { + if it.Key == KeyStorm && it.Status != ItemOK { + t.Fatalf("stale storm = %q, want ok", it.Status) + } + } +} + +type fakeStorm struct { + events []*stormguard.Event + err error +} + +func (f *fakeStorm) ListEvents(_ context.Context, _ int64, _ int) ([]*stormguard.Event, error) { + return f.events, f.err +} + +var _ StormEvents = (*fakeStorm)(nil) + +type fakeUpdates struct { + updates []*vehiclemodel.SoftwareUpdate + err error +} + +func (f *fakeUpdates) GetByVehicle(_ context.Context, _ int64, _ int, _, _ time.Time) ([]*vehiclemodel.SoftwareUpdate, error) { + return f.updates, f.err +} + +var _ UpdateHistory = (*fakeUpdates)(nil) + +type fakeRuns struct { + runs map[int64][]*Run + err error +} + +func (f *fakeRuns) SaveChecklistRun(_ context.Context, sessionID int64, items []Item) (*Run, error) { + if f.err != nil { + return nil, f.err + } + run := &Run{ID: int64(len(f.runs[sessionID]) + 1), SessionID: sessionID, RunAt: time.Now().UTC(), Items: items} + f.runs[sessionID] = append(f.runs[sessionID], run) + return run, nil +} + +func (f *fakeRuns) LatestChecklistRun(_ context.Context, sessionID int64) (*Run, error) { + if f.err != nil { + return nil, f.err + } + rs := f.runs[sessionID] + if len(rs) == 0 { + return nil, nil + } + return rs[len(rs)-1], nil +} + +var _ RunStore = (*fakeRuns)(nil) + +func TestNewChecklistHandlerPanicsOnNil(t *testing.T) { + f := newFakeStore() + r := &fakeRuns{runs: map[int64][]*Run{}} + l := &fakeLive{} + s := &fakeStorm{} + u := &fakeUpdates{} + cases := map[string]func(){ + "nil store": func() { NewChecklistHandler(nil, r, l, s, u) }, + "nil runs": func() { NewChecklistHandler(f, nil, l, s, u) }, + "nil live": func() { NewChecklistHandler(f, r, nil, s, u) }, + "nil storm": func() { NewChecklistHandler(f, r, l, nil, u) }, + "nil updates": func() { NewChecklistHandler(f, r, l, s, nil) }, + } + for name, fn := range cases { + func() { + defer func() { + if recover() == nil { + t.Fatalf("%s: expected panic", name) + } + }() + fn() + }() + } +} + +func checklistRequest(method, url, id string) *http.Request { + req := httptest.NewRequest(method, url, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", id) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestRefresh(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "north"}); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + h := NewChecklistHandler(f, &fakeRuns{runs: map[int64][]*Run{}}, &fakeLive{ + values: map[string]signal.SignalValue{ + "Soc": 85.0, "ChargeLimitSoc": 90.0, + "TpmsPressureFl": 2.9, "TpmsPressureFr": 3.0, + "TpmsPressureRl": 2.95, "TpmsPressureRr": 3.0, + }, + }, &fakeStorm{events: []*stormguard.Event{ + {Level: stormguard.LevelNone, CreatedAt: now}, + }}, &fakeUpdates{}) + rec := httptest.NewRecorder() + h.Refresh(rec, checklistRequest(http.MethodPost, "/journey/sessions/1/checklist/runs", "1")) + if rec.Code != http.StatusCreated { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Run + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SessionID != 1 || len(got.Items) != 5 { + t.Fatalf("run = %+v", got) + } + for _, it := range got.Items { + if it.Status != ItemOK { + t.Fatalf("%s = %q, want ok", it.Key, it.Status) + } + } +} + +func TestRefreshErrors(t *testing.T) { + f := newFakeStore() + h := NewChecklistHandler(f, &fakeRuns{runs: map[int64][]*Run{}}, &fakeLive{}, &fakeStorm{}, &fakeUpdates{}) + rec := httptest.NewRecorder() + h.Refresh(rec, checklistRequest(http.MethodPost, "/journey/sessions/9/checklist/runs", "9")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } + rec = httptest.NewRecorder() + h.Refresh(rec, checklistRequest(http.MethodPost, "/journey/sessions/x/checklist/runs", "x")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } +} + +func TestRefreshLiveDown(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "north"}); err != nil { + t.Fatal(err) + } + h := NewChecklistHandler(f, &fakeRuns{runs: map[int64][]*Run{}}, &fakeLive{err: errors.New("db down")}, &fakeStorm{}, &fakeUpdates{}) + rec := httptest.NewRecorder() + h.Refresh(rec, checklistRequest(http.MethodPost, "/journey/sessions/1/checklist/runs", "1")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", rec.Code) + } +} + +func TestLatest(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "north"}); err != nil { + t.Fatal(err) + } + runs := &fakeRuns{runs: map[int64][]*Run{}} + h := NewChecklistHandler(f, runs, &fakeLive{}, &fakeStorm{}, &fakeUpdates{}) + rec := httptest.NewRecorder() + h.Latest(rec, checklistRequest(http.MethodGet, "/journey/sessions/1/checklist", "1")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } + if _, err := runs.SaveChecklistRun(context.Background(), 1, EvaluateChecklist(Inputs{}, time.Now().UTC())); err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + h.Latest(rec, checklistRequest(http.MethodGet, "/journey/sessions/1/checklist", "1")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + var got Run + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Items) != 5 { + t.Fatalf("items = %d, want 5", len(got.Items)) + } +} diff --git a/internal/api/journey/departure.go b/internal/api/journey/departure.go new file mode 100644 index 000000000..2e36bfb62 --- /dev/null +++ b/internal/api/journey/departure.go @@ -0,0 +1,265 @@ +package journey + +import ( + "context" + "fmt" + "net/http" + "sort" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/api/stormguard" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +// Departure window bounds: advice covers at most 48h out, hourly. +const ( + defaultDepartureHorizon = 12 * time.Hour + maxDepartureHorizon = 48 * time.Hour +) + +// Slot is one scored departure hour. +type Slot struct { + DepartAt time.Time `json:"depart_at"` + Level string `json:"level"` + Score float64 `json:"score"` +} + +// ChargeContext is the live charge state at advice time. Nil fields mean +// the vehicle has never reported that signal (asleep or stale) — the +// advice degrades to weather-only rather than guessing. +type ChargeContext struct { + SocPct *float64 `json:"soc_pct"` + LimitPct *float64 `json:"limit_pct"` +} + +// DepartureAdvice is the ranked-slot response. +type DepartureAdvice struct { + SessionID int64 `json:"session_id"` + Slots []Slot `json:"slots"` + RecommendedAt *time.Time `json:"recommended_at"` + Charge *ChargeContext `json:"charge"` + Evidence []string `json:"evidence"` +} + +// RankDepartureSlots grades each hourly slot in [from, to] against the +// forecast: 100 for calm, 50 for watch, 0 for warning. Only slots +// covered by a forecast hour are emitted, so advice never outruns data. +// The recommendation is the earliest calm slot, else the earliest +// watch, else nil. Pure: no I/O, deterministic. +func RankDepartureSlots(f *stormguard.Forecast, from, to time.Time) ([]Slot, *time.Time) { + out := []Slot{} + if f == nil { + return out, nil + } + type hour struct { + code int + gust float64 + } + byHour := map[int64]hour{} + for i := range f.Times { + if i >= len(f.Weather) || i >= len(f.WindGustMS) { + break + } + byHour[f.Times[i].Unix()/3600] = hour{f.Weather[i], f.WindGustMS[i]} + } + start := from.Truncate(time.Hour) + if start.Before(from) { + start = start.Add(time.Hour) + } + for t := start; !t.After(to); t = t.Add(time.Hour) { + h, ok := byHour[t.Unix()/3600] + if !ok { + continue + } + level := stormguard.HourLevel(h.code, h.gust) + out = append(out, Slot{DepartAt: t, Level: level, Score: slotScore(level)}) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + return out[i].DepartAt.Before(out[j].DepartAt) + }) + if len(out) == 0 { + return out, nil + } + if out[0].Score == 0 { + return out, nil // every covered slot warns + } + best := out[0].DepartAt + return out, &best +} + +func slotScore(level string) float64 { + switch level { + case stormguard.LevelNone: + return 100 + case stormguard.LevelWatch: + return 50 + default: + return 0 + } +} + +// Meteo is the forecast port. *stormguard.Client satisfies it. +type Meteo interface { + Fetch(ctx context.Context, lat, lng float64) (*stormguard.Forecast, error) +} + +// LiveSignals is the minimal live-state port: the live-preferring +// reader, so pre-trip checks see fresh telemetry with signal_log +// backfill. signal.LiveStateReader satisfies it. +type LiveSignals interface { + LiveSignal(ctx context.Context, vehicleID int64, name string) (signal.SignalValue, error) +} + +// DepartureHandler serves the departure advisor. Stateless beyond +// constructor inputs; safe for concurrent use. +type DepartureHandler struct { + store SessionStore + meteo Meteo + live LiveSignals + now func() time.Time +} + +// NewDepartureHandler wires the handler. Panics on nil inputs +// (fail-fast wiring contract, matching sibling handlers). +func NewDepartureHandler(store SessionStore, meteo Meteo, live LiveSignals) *DepartureHandler { + if store == nil || meteo == nil || live == nil { + panic("journey: nil dependency") + } + return &DepartureHandler{store: store, meteo: meteo, live: live, now: time.Now} +} + +// Advise serves GET /journey/sessions/{id}/departure?from=&to=: ranked +// departure slots with a recommendation. from/to are RFC3339; default +// now → +12h; the window clamps to 48h. +func (h *DepartureHandler) Advise(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + now := h.now().UTC() + from, to := now, now.Add(defaultDepartureHorizon) + if s := r.URL.Query().Get("from"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "from must be RFC3339") + return + } + from = t + } + if s := r.URL.Query().Get("to"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "to must be RFC3339") + return + } + to = t + } + if from.Before(now.Add(-time.Hour)) { + from = now + } + if to.After(from.Add(maxDepartureHorizon)) { + to = from.Add(maxDepartureHorizon) + } + if !to.After(from) { + httpx.WriteError(w, http.StatusBadRequest, "to must be after from") + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + if session.OriginLat == nil || session.OriginLng == nil { + httpx.WriteError(w, http.StatusBadRequest, "journey needs origin coordinates for departure advice") + return + } + forecast, err := h.meteo.Fetch(ctx, *session.OriginLat, *session.OriginLng) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: forecast fetch failed") + httpx.WriteError(w, http.StatusBadGateway, "weather forecast unavailable") + return + } + slots, recommended := RankDepartureSlots(forecast, from, to) + charge, err := h.chargeContext(ctx, session.VehicleID) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: live state failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read vehicle state") + return + } + httpx.WriteJSON(w, http.StatusOK, DepartureAdvice{ + SessionID: id, + Slots: slots, + RecommendedAt: recommended, + Charge: charge, + Evidence: departureEvidence(slots, recommended, charge), + }) +} + +func (h *DepartureHandler) chargeContext(ctx context.Context, vehicleID int64) (*ChargeContext, error) { + soc, err := signalFloat(h.live, ctx, vehicleID, "Soc") + if err != nil { + return nil, err + } + limit, err := signalFloat(h.live, ctx, vehicleID, "ChargeLimitSoc") + if err != nil { + return nil, err + } + return &ChargeContext{SocPct: soc, LimitPct: limit}, nil +} + +// signalFloat reads one float signal. (nil, nil) when never observed +// in either layer; errors only on transport/query failure. +func signalFloat(live LiveSignals, ctx context.Context, vehicleID int64, name string) (*float64, error) { + v, err := live.LiveSignal(ctx, vehicleID, name) + if err != nil { + return nil, err + } + if v == nil { + return nil, nil + } + f, ok := signal.Float64(v) + if !ok { + return nil, nil + } + return &f, nil +} + +func departureEvidence(slots []Slot, recommended *time.Time, charge *ChargeContext) []string { + out := []string{} + warn, watch := 0, 0 + for _, s := range slots { + switch s.Level { + case stormguard.LevelWarning: + warn++ + case stormguard.LevelWatch: + watch++ + } + } + out = append(out, fmt.Sprintf("%d slots scored, %d warning, %d watch", len(slots), warn, watch)) + if recommended != nil { + out = append(out, "earliest calm slot "+recommended.Format("Mon 15:04")) + } else if len(slots) > 0 { + out = append(out, "every covered slot warns — delay or ride it out") + } else { + out = append(out, "forecast covers none of the window") + } + if charge != nil && charge.SocPct != nil { + out = append(out, fmt.Sprintf("battery at %.0f%% now", *charge.SocPct)) + } else { + out = append(out, "vehicle state stale — weather-only advice") + } + return out +} diff --git a/internal/api/journey/departure_test.go b/internal/api/journey/departure_test.go new file mode 100644 index 000000000..6a02951a7 --- /dev/null +++ b/internal/api/journey/departure_test.go @@ -0,0 +1,220 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/ev-dev-labs/teslasync/internal/api/stormguard" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +func departForecast(base time.Time) *stormguard.Forecast { + times := make([]time.Time, 0, 6) + codes := []int{1, 95, 1, 80, 1, 1} + gusts := []float64{8, 9, 30, 9, 18, 8} + for i := range codes { + times = append(times, base.Add(time.Duration(i)*time.Hour)) + } + return &stormguard.Forecast{Times: times, Weather: codes, WindGustMS: gusts} +} + +func TestRankDepartureSlots(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + slots, rec := RankDepartureSlots(departForecast(base), base, base.Add(6*time.Hour)) + if len(slots) != 6 { + t.Fatalf("slots = %d, want 6", len(slots)) + } + // Sorted by score desc, then time: calm hours first. + if slots[0].Score != 100 || !slots[0].DepartAt.Equal(base) { + t.Fatalf("first = %+v, want calm 10:00", slots[0]) + } + if rec == nil || !rec.Equal(base) { + t.Fatalf("recommended = %v, want 10:00", rec) + } + levels := map[string]int{} + for _, s := range slots { + levels[s.Level]++ + } + if levels[stormguard.LevelWarning] != 2 || levels[stormguard.LevelWatch] != 2 || levels[stormguard.LevelNone] != 2 { + t.Fatalf("levels = %v", levels) + } +} + +func TestRankDepartureSlotsSkipsUncovered(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + slots, rec := RankDepartureSlots(departForecast(base), base.Add(24*time.Hour), base.Add(30*time.Hour)) + if len(slots) != 0 || rec != nil { + t.Fatalf("uncovered = %+v %v, want empty", slots, rec) + } + if slots, rec := RankDepartureSlots(nil, base, base.Add(6*time.Hour)); len(slots) != 0 || rec != nil { + t.Fatalf("nil forecast = %+v %v, want empty", slots, rec) + } +} + +func TestRankDepartureSlotsAllWarn(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + f := &stormguard.Forecast{ + Times: []time.Time{base, base.Add(time.Hour)}, + Weather: []int{95, 99}, + WindGustMS: []float64{9, 9}, + } + slots, rec := RankDepartureSlots(f, base, base.Add(2*time.Hour)) + if len(slots) != 2 || rec != nil { + t.Fatalf("all-warn = %+v %v, want slots without recommendation", slots, rec) + } +} + +type fakeMeteo struct { + forecast *stormguard.Forecast + err error +} + +func (f *fakeMeteo) Fetch(_ context.Context, _, _ float64) (*stormguard.Forecast, error) { + return f.forecast, f.err +} + +var _ Meteo = (*fakeMeteo)(nil) + +type fakeLive struct { + values map[string]signal.SignalValue + err error +} + +func (f *fakeLive) LiveSignal(_ context.Context, _ int64, name string) (signal.SignalValue, error) { + if f.err != nil { + return nil, f.err + } + return f.values[name], nil +} + +var _ LiveSignals = (*fakeLive)(nil) + +func adviseRequest(url, id string) *http.Request { + req := httptest.NewRequest(http.MethodGet, url, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", id) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestNewDepartureHandlerPanicsOnNil(t *testing.T) { + f := newFakeStore() + m := &fakeMeteo{} + l := &fakeLive{} + cases := map[string]func(){ + "nil store": func() { NewDepartureHandler(nil, m, l) }, + "nil meteo": func() { NewDepartureHandler(f, nil, l) }, + "nil live": func() { NewDepartureHandler(f, m, nil) }, + } + for name, fn := range cases { + func() { + defer func() { + if recover() == nil { + t.Fatalf("%s: expected panic", name) + } + }() + fn() + }() + } +} + +func TestAdvise(t *testing.T) { + f := newFakeStore() + now := time.Date(2026, 9, 14, 9, 30, 0, 0, time.UTC) + s, err := f.Create(context.Background(), NewSession{ + VehicleID: 7, Name: "north", + OriginLat: fptr(37.0), OriginLng: fptr(-122.0), + DestLat: fptr(39.0), DestLng: fptr(-120.0), + }) + if err != nil { + t.Fatal(err) + } + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + h := NewDepartureHandler(f, &fakeMeteo{forecast: departForecast(base)}, &fakeLive{ + values: map[string]signal.SignalValue{"Soc": 82.0, "ChargeLimitSoc": 90.0}, + }) + h.now = func() time.Time { return now } + rec := httptest.NewRecorder() + h.Advise(rec, adviseRequest("/journey/sessions/1/departure", "1")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got DepartureAdvice + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SessionID != s.ID || len(got.Slots) != 6 { + t.Fatalf("advice = %+v", got) + } + if got.RecommendedAt == nil || !got.RecommendedAt.Equal(base) { + t.Fatalf("recommended = %v, want 10:00", got.RecommendedAt) + } + if got.Charge == nil || got.Charge.SocPct == nil || *got.Charge.SocPct != 82 { + t.Fatalf("charge = %+v", got.Charge) + } + if len(got.Evidence) == 0 { + t.Fatal("evidence is empty") + } +} + +func TestAdviseErrors(t *testing.T) { + f := newFakeStore() + h := NewDepartureHandler(f, &fakeMeteo{}, &fakeLive{}) + h.now = func() time.Time { return time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) } + cases := []struct { + name string + url string + id string + code int + }{ + {"bad id", "/journey/sessions/x/departure", "x", http.StatusBadRequest}, + {"bad from", "/journey/sessions/1/departure?from=soon", "1", http.StatusBadRequest}, + {"bad to", "/journey/sessions/1/departure?to=later", "1", http.StatusBadRequest}, + {"missing session", "/journey/sessions/9/departure", "9", http.StatusNotFound}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.Advise(rec, adviseRequest(c.url, c.id)) + if rec.Code != c.code { + t.Fatalf("code = %d, want %d", rec.Code, c.code) + } + }) + } +} + +func TestAdviseNeedsOrigin(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "vague"}); err != nil { + t.Fatal(err) + } + h := NewDepartureHandler(f, &fakeMeteo{}, &fakeLive{}) + rec := httptest.NewRecorder() + h.Advise(rec, adviseRequest("/journey/sessions/1/departure", "1")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } +} + +func TestAdviseMeteoDown(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{ + VehicleID: 7, Name: "north", + OriginLat: fptr(37.0), OriginLng: fptr(-122.0), + DestLat: fptr(39.0), DestLng: fptr(-120.0), + }); err != nil { + t.Fatal(err) + } + h := NewDepartureHandler(f, &fakeMeteo{err: errors.New("meteo down")}, &fakeLive{}) + rec := httptest.NewRecorder() + h.Advise(rec, adviseRequest("/journey/sessions/1/departure", "1")) + if rec.Code != http.StatusBadGateway { + t.Fatalf("code = %d, want 502", rec.Code) + } +} diff --git a/internal/api/journey/handler.go b/internal/api/journey/handler.go index ea6dd629d..eb2c20da9 100644 --- a/internal/api/journey/handler.go +++ b/internal/api/journey/handler.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" + "time" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/api/waitoracle" ) // SessionStore is the session/plan port. *Store satisfies it. @@ -24,19 +27,33 @@ type SessionStore interface { ListPlans(ctx context.Context, sessionID int64) ([]*PlanVersion, error) } +// SignalStore is the price/health port. *Store satisfies it. +type SignalStore interface { + SitePeaks(ctx context.Context, site string) ([]float64, error) + SitePrice(ctx context.Context, site string) (perKWh float64, samples int, ok bool, err error) +} + +// WaitStore is the demand-history port. *waitoracle.Store satisfies it. +type WaitStore interface { + History(ctx context.Context, site string) (waitoracle.SiteHistory, error) +} + // Handler serves journey sessions + plan versions. Stateless beyond // constructor inputs; safe for concurrent use. type Handler struct { - store SessionStore + store SessionStore + signals SignalStore + waits WaitStore + now func() time.Time } -// NewHandler wires the handler. Panics on nil input (fail-fast wiring +// NewHandler wires the handler. Panics on nil inputs (fail-fast wiring // contract, matching sibling handlers). -func NewHandler(store SessionStore) *Handler { - if store == nil { +func NewHandler(store SessionStore, signals SignalStore, waits WaitStore) *Handler { + if store == nil || signals == nil || waits == nil { panic("journey: nil dependency") } - return &Handler{store: store} + return &Handler{store: store, signals: signals, waits: waits, now: time.Now} } type createRequest struct { @@ -271,6 +288,159 @@ func clampListLimit(n int) int { return n } +// maxCandidates bounds the score request: each candidate costs up to +// three history reads, gathered sequentially. +const maxCandidates = 10 + +type scoreCandidateRequest struct { + Site string `json:"site"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Arrive string `json:"arrive_at"` +} + +type scoreRequest struct { + Candidates []scoreCandidateRequest `json:"candidates"` + EnergyWh float64 `json:"energy_wh"` +} + +type scoreResponse struct { + SessionID int64 `json:"session_id"` + EnergyWh float64 `json:"energy_wh"` + Stops []ScoredStop `json:"stops"` + Winner string `json:"winner"` + PlanVersion int `json:"plan_version"` +} + +// ScoreStops serves POST /journey/sessions/{id}/score-stops: rank +// caller-nominated candidate stops on predicted wait, realized price, +// stall health, and corridor deviation — then persist the ranking as a +// plan version. Per-candidate gaps degrade (the signal drops out); +// infrastructure failures fail the request. +func (h *Handler) ScoreStops(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + var req scoreRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if len(req.Candidates) == 0 || len(req.Candidates) > maxCandidates { + httpx.WriteError(w, http.StatusBadRequest, "candidates must hold 1..10 stops") + return + } + if req.EnergyWh <= 0 || req.EnergyWh > 200000 { + httpx.WriteError(w, http.StatusBadRequest, "energy_wh must be within 0..200000") + return + } + arrivals := make([]time.Time, len(req.Candidates)) + for i, c := range req.Candidates { + if c.Site == "" { + httpx.WriteError(w, http.StatusBadRequest, "candidate site must be non-empty") + return + } + if c.Lat < -90 || c.Lat > 90 || c.Lng < -180 || c.Lng > 180 { + httpx.WriteError(w, http.StatusBadRequest, "candidate lat/lng out of range") + return + } + t, err := time.Parse(time.RFC3339, c.Arrive) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "candidate arrive_at must be RFC3339") + return + } + arrivals[i] = t + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + if session.OriginLat == nil || session.OriginLng == nil || session.DestLat == nil || session.DestLng == nil { + httpx.WriteError(w, http.StatusBadRequest, "journey needs origin and destination coordinates to score stops") + return + } + cands := make([]Candidate, len(req.Candidates)) + sigs := make([]Signals, len(req.Candidates)) + for i, c := range req.Candidates { + cands[i] = Candidate{Site: c.Site, Lat: c.Lat, Lng: c.Lng, ArriveS: arrivals[i].Unix()} + sig, err := gatherSiteSignals(ctx, h.signals, h.waits, c.Site, arrivals[i]) + if err != nil { + log.Error().Err(err).Str("site", c.Site).Msg("journey: signals failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read stop signals") + return + } + sigs[i] = sig + } + stops := RankStops(*session.OriginLat, *session.OriginLng, *session.DestLat, *session.DestLng, req.EnergyWh, cands, sigs) + plan, err := json.Marshal(map[string]any{ + "kind": "stop_scores", "energy_wh": req.EnergyWh, "stops": stops, + "candidates": candidateEcho(cands), + }) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: plan encode failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save plan") + return + } + pv, err := h.store.SavePlan(ctx, id, plan, fmt.Sprintf("stop scores (%d candidates)", len(cands))) + if err != nil { + if errors.Is(err, ErrNoSession) { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + log.Error().Err(err).Int64("id", id).Msg("journey: save plan failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save plan") + return + } + winner := "" + if len(stops) > 0 { + winner = stops[0].Site + } + httpx.WriteJSON(w, http.StatusOK, scoreResponse{ + SessionID: id, EnergyWh: req.EnergyWh, Stops: stops, + Winner: winner, PlanVersion: pv.Version, + }) +} + +// gatherSiteSignals reads wait, price, and peak samples for one site. +// Thin history (ErrNoHistory, unpriced, unmetered) yields nils; anything +// else is an infrastructure failure. Shared by initial scoring and +// replans so both rank on identical inputs. +func gatherSiteSignals(ctx context.Context, signals SignalStore, waits WaitStore, site string, arrival time.Time) (Signals, error) { + var sig Signals + history, err := waits.History(ctx, site) + if err != nil && !errors.Is(err, waitoracle.ErrNoHistory) { + return sig, err + } + if err == nil { + if f, err := waitoracle.Predict(history, arrival); err == nil { + sig.WaitS = &f.ExpectedS + } else if !errors.Is(err, waitoracle.ErrNoHistory) { + return sig, err + } + } + peaks, err := signals.SitePeaks(ctx, site) + if err != nil { + return sig, err + } + sig.PeakKW = peaks + if perKWh, _, ok, err := signals.SitePrice(ctx, site); err != nil { + return sig, err + } else if ok { + sig.PerKWh = &perKWh + } + sig.Available = sig.WaitS != nil || sig.PerKWh != nil || len(sig.PeakKW) >= 3 + return sig, nil +} + func sessionIDParam(r *http.Request) (int64, error) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil || id <= 0 { @@ -296,5 +466,9 @@ const ( errBadVehicleID = paramError("vehicle_id must be a positive integer") ) -// Compile-time port assertion. -var _ SessionStore = (*Store)(nil) +// Compile-time port assertions. +var ( + _ SessionStore = (*Store)(nil) + _ SignalStore = (*Store)(nil) + _ WaitStore = (*waitoracle.Store)(nil) +) diff --git a/internal/api/journey/handler_test.go b/internal/api/journey/handler_test.go index 49e22b97b..cf2c0ac5a 100644 --- a/internal/api/journey/handler_test.go +++ b/internal/api/journey/handler_test.go @@ -11,17 +11,27 @@ import ( "time" "github.com/go-chi/chi/v5" + + "github.com/ev-dev-labs/teslasync/internal/api/waitoracle" ) type fakeStore struct { sessions map[int64]*Session plans map[int64][]*PlanVersion + peaks map[string][]float64 + prices map[string]float64 nextID int64 err error } func newFakeStore() *fakeStore { - return &fakeStore{sessions: map[int64]*Session{}, plans: map[int64][]*PlanVersion{}, nextID: 1} + return &fakeStore{ + sessions: map[int64]*Session{}, + plans: map[int64][]*PlanVersion{}, + peaks: map[string][]float64{}, + prices: map[string]float64{}, + nextID: 1, + } } func (f *fakeStore) Create(_ context.Context, in NewSession) (*Session, error) { @@ -109,7 +119,48 @@ func (f *fakeStore) ListPlans(_ context.Context, sessionID int64) ([]*PlanVersio return f.plans[sessionID], f.err } +func (f *fakeStore) SitePeaks(_ context.Context, site string) ([]float64, error) { + if f.err != nil { + return nil, f.err + } + return f.peaks[site], nil +} + +func (f *fakeStore) SitePrice(_ context.Context, site string) (float64, int, bool, error) { + if f.err != nil { + return 0, 0, false, f.err + } + p, ok := f.prices[site] + if !ok { + return 0, 0, false, nil + } + return p, 12, true, nil +} + var _ SessionStore = (*fakeStore)(nil) +var _ SignalStore = (*fakeStore)(nil) + +type fakeWaits struct { + histories map[string]waitoracle.SiteHistory + err error +} + +func (f *fakeWaits) History(_ context.Context, site string) (waitoracle.SiteHistory, error) { + if f.err != nil { + return waitoracle.SiteHistory{}, f.err + } + h, ok := f.histories[site] + if !ok { + return waitoracle.SiteHistory{}, waitoracle.ErrNoHistory + } + return h, nil +} + +var _ WaitStore = (*fakeWaits)(nil) + +func testHandler(f *fakeStore) *Handler { + return NewHandler(f, f, &fakeWaits{histories: map[string]waitoracle.SiteHistory{}}) +} func withID(t *testing.T, method, target string, id string) *http.Request { t.Helper() @@ -120,16 +171,27 @@ func withID(t *testing.T, method, target string, id string) *http.Request { } func TestNewHandlerPanicsOnNil(t *testing.T) { - defer func() { - if recover() == nil { - t.Fatal("expected panic") - } - }() - NewHandler(nil) + f := newFakeStore() + w := &fakeWaits{} + cases := map[string]func(){ + "nil store": func() { NewHandler(nil, f, w) }, + "nil signals": func() { NewHandler(f, nil, w) }, + "nil waits": func() { NewHandler(f, f, nil) }, + } + for name, fn := range cases { + func() { + defer func() { + if recover() == nil { + t.Fatalf("%s: expected panic", name) + } + }() + fn() + }() + } } func TestCreate(t *testing.T) { - h := NewHandler(newFakeStore()) + h := testHandler(newFakeStore()) body := `{"vehicle_id":7,"name":"Tahoe ski trip","origin_name":"Home","dest_name":"Tahoe","dest_lat":39.1,"dest_lng":-120.0}` req := httptest.NewRequest(http.MethodPost, "/journey/sessions", strings.NewReader(body)) rec := httptest.NewRecorder() @@ -147,7 +209,7 @@ func TestCreate(t *testing.T) { } func TestCreateValidation(t *testing.T) { - h := NewHandler(newFakeStore()) + h := testHandler(newFakeStore()) cases := map[string]string{ "bad json": `{oops`, "missing vehicle": `{"vehicle_id":0,"name":"x"}`, @@ -169,7 +231,7 @@ func TestCreateValidation(t *testing.T) { func TestListFiltersByVehicleAndStatus(t *testing.T) { f := newFakeStore() - h := NewHandler(f) + h := testHandler(f) ctx := context.Background() if _, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "a"}); err != nil { t.Fatal(err) @@ -218,7 +280,7 @@ func TestClampListLimit(t *testing.T) { } func TestListValidation(t *testing.T) { - h := NewHandler(newFakeStore()) + h := testHandler(newFakeStore()) for _, url := range []string{ "/journey/sessions", "/journey/sessions?vehicle_id=0", @@ -235,7 +297,7 @@ func TestListValidation(t *testing.T) { func TestGetIncludesPlansAndNext(t *testing.T) { f := newFakeStore() - h := NewHandler(f) + h := testHandler(f) ctx := context.Background() s, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "a"}) if err != nil { @@ -262,7 +324,7 @@ func TestGetIncludesPlansAndNext(t *testing.T) { } func TestGetNotFound(t *testing.T) { - h := NewHandler(newFakeStore()) + h := testHandler(newFakeStore()) rec := httptest.NewRecorder() h.Get(rec, withID(t, http.MethodGet, "/journey/sessions/9", "9")) if rec.Code != http.StatusNotFound { @@ -277,7 +339,7 @@ func TestGetNotFound(t *testing.T) { func TestStartRejectsSecondActive(t *testing.T) { f := newFakeStore() - h := NewHandler(f) + h := testHandler(f) ctx := context.Background() a, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "a"}) if err != nil { @@ -298,7 +360,7 @@ func TestStartRejectsSecondActive(t *testing.T) { func TestLifecycleTransitions(t *testing.T) { f := newFakeStore() - h := NewHandler(f) + h := testHandler(f) s, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "a"}) if err != nil { t.Fatal(err) @@ -335,7 +397,7 @@ func TestLifecycleTransitions(t *testing.T) { func TestSavePlan(t *testing.T) { f := newFakeStore() - h := NewHandler(f) + h := testHandler(f) s, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "a"}) if err != nil { t.Fatal(err) @@ -360,7 +422,7 @@ func TestSavePlan(t *testing.T) { } func TestSavePlanErrors(t *testing.T) { - h := NewHandler(newFakeStore()) + h := testHandler(newFakeStore()) // Missing session. body := `{"plan":{},"note":"x"}` req := httptest.NewRequest(http.MethodPost, "/journey/sessions/9/plans", strings.NewReader(body)) @@ -386,7 +448,7 @@ func TestSavePlanErrors(t *testing.T) { } func TestStoreErrorSurfaces500(t *testing.T) { - h := NewHandler(&fakeStore{err: errors.New("db down"), sessions: map[int64]*Session{}, plans: map[int64][]*PlanVersion{}}) + h := testHandler(&fakeStore{err: errors.New("db down"), sessions: map[int64]*Session{}, plans: map[int64][]*PlanVersion{}}) req := httptest.NewRequest(http.MethodGet, "/journey/sessions?vehicle_id=7", nil) rec := httptest.NewRecorder() h.List(rec, req) @@ -394,3 +456,149 @@ func TestStoreErrorSurfaces500(t *testing.T) { t.Fatalf("code = %d, want 500", rec.Code) } } + +func fptr(v float64) *float64 { return &v } + +func scoredHistory(site string) waitoracle.SiteHistory { + base := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) + spans := make([]waitoracle.Session, 0, 20) + for i := 0; i < 20; i++ { + s := base.Add(time.Duration(i) * time.Hour) + spans = append(spans, waitoracle.Session{Start: s, Stop: s.Add(30 * time.Minute)}) + } + return waitoracle.SiteHistory{ + Site: site, Sessions: 100, Weeks: 10, + Buckets: []waitoracle.Bucket{{Weekday: 5, Hour: 18, Starts: 60}}, + Spans: spans, + } +} + +func scoreHTTPRequest(t *testing.T, id, body string) (*httptest.ResponseRecorder, *http.Request) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/journey/sessions/"+id+"/score-stops", strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", id) + return httptest.NewRecorder(), req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestScoreStops(t *testing.T) { + f := newFakeStore() + s, err := f.Create(context.Background(), NewSession{ + VehicleID: 7, Name: "north", + OriginLat: fptr(37.0), OriginLng: fptr(-122.0), + DestLat: fptr(39.0), DestLng: fptr(-120.0), + }) + if err != nil { + t.Fatal(err) + } + f.prices["Kettleman"] = 0.30 + f.peaks["Kettleman"] = []float64{150, 151, 149, 150, 152} + w := &fakeWaits{histories: map[string]waitoracle.SiteHistory{ + "Kettleman": scoredHistory("Kettleman"), + }} + h := NewHandler(f, f, w) + body := `{"energy_wh": 40000, "candidates": [ + {"site": "Kettleman", "lat": 38.0, "lng": -121.0, "arrive_at": "2026-09-11T18:00:00Z"}, + {"site": "Nowhere", "lat": 38.0, "lng": -118.0, "arrive_at": "2026-09-11T18:00:00Z"} + ]}` + rec, req := scoreHTTPRequest(t, "1", body) + h.ScoreStops(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got scoreResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Winner != "Kettleman" { + t.Fatalf("winner = %q, want Kettleman", got.Winner) + } + if len(got.Stops) != 2 || got.Stops[0].Score < got.Stops[1].Score { + t.Fatalf("stops not ranked: %+v", got.Stops) + } + if got.Stops[0].WaitS == nil || got.Stops[0].UnitPrice == nil || got.Stops[0].Health == nil { + t.Fatalf("winner missing signals: %+v", got.Stops[0]) + } + if got.Stops[1].WaitS != nil || got.Stops[1].UnitPrice != nil { + t.Fatalf("thin site should degrade: %+v", got.Stops[1]) + } + if got.PlanVersion != 1 || s.PlanVersion != 1 || len(f.plans[s.ID]) != 1 { + t.Fatalf("plan not persisted: %+v", got) + } + if f.plans[s.ID][0].Note != "stop scores (2 candidates)" { + t.Fatalf("note = %q", f.plans[s.ID][0].Note) + } +} + +func TestScoreStopsValidation(t *testing.T) { + h := testHandler(newFakeStore()) + cand := `{"site": "K", "lat": 38.0, "lng": -121.0, "arrive_at": "2026-09-11T18:00:00Z"}` + many := `{"energy_wh": 1000, "candidates": [` + strings.Repeat(cand+",", 10) + cand + `]}` + cases := map[string]string{ + "bad json": `{oops`, + "no candidates": `{"energy_wh": 1000, "candidates": []}`, + "too many": many, + "no energy": `{"energy_wh": 0, "candidates": [` + cand + `]}`, + "huge energy": `{"energy_wh": 999999, "candidates": [` + cand + `]}`, + "empty site": `{"energy_wh": 1000, "candidates": [{"site": "", "lat": 0, "lng": 0, "arrive_at": "2026-09-11T18:00:00Z"}]}`, + "bad lat": `{"energy_wh": 1000, "candidates": [{"site": "K", "lat": 99, "lng": 0, "arrive_at": "2026-09-11T18:00:00Z"}]}`, + "bad arrival": `{"energy_wh": 1000, "candidates": [{"site": "K", "lat": 0, "lng": 0, "arrive_at": "soon"}]}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + rec, req := scoreHTTPRequest(t, "1", body) + h.ScoreStops(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } + }) + } +} + +func TestScoreStopsNeedsCoords(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "vague"}); err != nil { + t.Fatal(err) + } + h := testHandler(f) + body := `{"energy_wh": 1000, "candidates": [ + {"site": "K", "lat": 38.0, "lng": -121.0, "arrive_at": "2026-09-11T18:00:00Z"} + ]}` + rec, req := scoreHTTPRequest(t, "1", body) + h.ScoreStops(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } +} + +func TestScoreStopsNotFound(t *testing.T) { + h := testHandler(newFakeStore()) + body := `{"energy_wh": 1000, "candidates": [ + {"site": "K", "lat": 38.0, "lng": -121.0, "arrive_at": "2026-09-11T18:00:00Z"} + ]}` + rec, req := scoreHTTPRequest(t, "9", body) + h.ScoreStops(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} + +func TestScoreStopsSignalsError(t *testing.T) { + f := newFakeStore() + if _, err := f.Create(context.Background(), NewSession{ + VehicleID: 7, Name: "north", + OriginLat: fptr(37.0), OriginLng: fptr(-122.0), + DestLat: fptr(39.0), DestLng: fptr(-120.0), + }); err != nil { + t.Fatal(err) + } + h := NewHandler(f, f, &fakeWaits{err: errors.New("db down")}) + body := `{"energy_wh": 1000, "candidates": [ + {"site": "K", "lat": 38.0, "lng": -121.0, "arrive_at": "2026-09-11T18:00:00Z"} + ]}` + rec, req := scoreHTTPRequest(t, "1", body) + h.ScoreStops(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", rec.Code) + } +} diff --git a/internal/api/journey/learn.go b/internal/api/journey/learn.go new file mode 100644 index 000000000..501ab0396 --- /dev/null +++ b/internal/api/journey/learn.go @@ -0,0 +1,63 @@ +package journey + +import ( + "context" + "math" +) + +// routeHistoryLimit bounds the learning lookback: the ten most recent +// completed trips on a route. +const routeHistoryLimit = 10 + +// routeFactorBounds clamp the learned multiplier. Below 1.0 the +// history claims the road beats the straight line (partial trips, +// swapped endpoints) — clamped to the line. Above 2.0 a detour-heavy +// past would double every ETA — clamped to twice. +const ( + routeFactorMin = 1.0 + routeFactorMax = 2.0 +) + +// minRouteTrips is the evidence floor: one trip is an anecdote, two a +// pattern. +const minRouteTrips = 2 + +// RouteFactor averages per-trip detour ratios into one multiplier. +// Legs with non-positive distance or straight legs drop out (partial +// data, not evidence). Needs minRouteTrips contributors; the mean +// clamps to [routeFactorMin, routeFactorMax]. Returns the contributor +// count for transparency. Pure. +func RouteFactor(legs []RouteLeg) (factor float64, trips int, ok bool) { + sum := 0.0 + for _, leg := range legs { + if leg.DistanceM <= 0 || leg.StraightM <= 0 { + continue + } + sum += leg.DistanceM / leg.StraightM + trips++ + } + if trips < minRouteTrips { + return 0, trips, false + } + mean := sum / float64(trips) + return math.Min(routeFactorMax, math.Max(routeFactorMin, mean)), trips, true +} + +// routeFactorFor resolves the learned multiplier for a session's +// route: both endpoint names identify the route, in either direction. +// Nil factor without names or history — callers fall back to the +// straight line. +func routeFactorFor(ctx context.Context, trail TrailStore, session *Session) (factor *float64, trips int, err error) { + if session.OriginName == "" || session.DestName == "" { + return nil, 0, nil + } + legs, err := trail.RouteLegs(ctx, session.VehicleID, session.OriginName, session.DestName, routeHistoryLimit) + if err != nil { + return nil, 0, err + } + f, n, ok := RouteFactor(legs) + if !ok { + return nil, n, nil + } + return &f, n, nil +} diff --git a/internal/api/journey/learn_test.go b/internal/api/journey/learn_test.go new file mode 100644 index 000000000..1a7c5b4e6 --- /dev/null +++ b/internal/api/journey/learn_test.go @@ -0,0 +1,69 @@ +package journey + +import ( + "context" + "errors" + "testing" +) + +func TestRouteFactor(t *testing.T) { + f, trips, ok := RouteFactor([]RouteLeg{ + {DistanceM: 99000, StraightM: 90000}, + {DistanceM: 90000, StraightM: 90000}, + }) + if !ok || trips != 2 || f < 1.049 || f > 1.051 { + t.Fatalf("factor = %f/%d/%v, want 1.05/2/true", f, trips, ok) + } + if _, _, ok := RouteFactor([]RouteLeg{{DistanceM: 99000, StraightM: 90000}}); ok { + t.Fatal("single trip should not make a factor") + } + if _, n, ok := RouteFactor(nil); ok || n != 0 { + t.Fatalf("nil = %d/%v, want 0/false", n, ok) + } + // Partial legs drop out instead of poisoning the mean. + f, trips, ok = RouteFactor([]RouteLeg{ + {DistanceM: 0, StraightM: 90000}, + {DistanceM: 99000, StraightM: 0}, + {DistanceM: 99000, StraightM: 90000}, + {DistanceM: 90000, StraightM: 90000}, + }) + if !ok || trips != 2 || f < 1.049 || f > 1.051 { + t.Fatalf("filtered = %f/%d/%v, want 1.05/2/true", f, trips, ok) + } + if f, _, ok := RouteFactor([]RouteLeg{ + {DistanceM: 300000, StraightM: 100000}, + {DistanceM: 400000, StraightM: 100000}, + }); !ok || f != 2.0 { + t.Fatalf("high clamp = %f/%v, want 2.0", f, ok) + } + if f, _, ok := RouteFactor([]RouteLeg{ + {DistanceM: 50000, StraightM: 100000}, + {DistanceM: 60000, StraightM: 100000}, + }); !ok || f != 1.0 { + t.Fatalf("low clamp = %f/%v, want 1.0", f, ok) + } +} + +func TestRouteFactorFor(t *testing.T) { + ctx := context.Background() + legs := []RouteLeg{ + {DistanceM: 99000, StraightM: 90000}, + {DistanceM: 90000, StraightM: 90000}, + } + s := liveSession() + s.OriginName, s.DestName = "Denver", "KC" + f, n, err := routeFactorFor(ctx, &fakeTrail{legs: legs}, s) + if err != nil || f == nil || n != 2 || *f < 1.049 || *f > 1.051 { + t.Fatalf("factor = %v/%d/%v", f, n, err) + } + unnamed := liveSession() + if f, n, err := routeFactorFor(ctx, &fakeTrail{legs: legs}, unnamed); err != nil || f != nil || n != 0 { + t.Fatalf("unnamed = %v/%d/%v, want nil/0", f, n, err) + } + if f, _, err := routeFactorFor(ctx, &fakeTrail{legs: legs[:1]}, s); err != nil || f != nil { + t.Fatalf("thin = %v/%v, want nil", f, err) + } + if _, _, err := routeFactorFor(ctx, &fakeTrail{legs: legs, err: errors.New("db down")}, s); err == nil { + t.Fatal("store error should propagate") + } +} diff --git a/internal/api/journey/live.go b/internal/api/journey/live.go new file mode 100644 index 000000000..69dae1122 --- /dev/null +++ b/internal/api/journey/live.go @@ -0,0 +1,392 @@ +package journey + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Live-view constants: the range buffer and the checkpoint future +// tolerance for clock-skewed companions. +const ( + rangeBuffer = 1.15 + futureTolerance = 5 * time.Minute +) + +// Progress is straight-line trip progress in SI meters. A documented +// proxy until committed turn-by-turn legs exist (replan engine). +type Progress struct { + TotalM float64 `json:"total_m"` + DoneM float64 `json:"done_m"` + LeftM float64 `json:"left_m"` +} + +// Range ties remaining energy to remaining distance. +type Range struct { + HaveWh *float64 `json:"have_wh"` + NeedWh *float64 `json:"need_wh"` + EffWhKm *float64 `json:"eff_wh_km"` + Verdict string `json:"verdict"` // ok, attention, action, unknown +} + +// NextStop is the head of the latest scored plan, if any. +type NextStop struct { + Site string `json:"site"` + WaitS *float64 `json:"wait_s"` +} + +// LiveView is the glanceable GET response. +type LiveView struct { + Session *Session `json:"session"` + Latest *Checkpoint `json:"latest"` + Trail []*Checkpoint `json:"trail"` + Progress *Progress `json:"progress"` + Range *Range `json:"range"` + Next *NextStop `json:"next"` + Evidence []string `json:"evidence"` +} + +// haversineM returns great-circle meters between two points. +func haversineM(lat1, lng1, lat2, lng2 float64) float64 { + const earthM = 6371000.0 + toRad := func(d float64) float64 { return d * math.Pi / 180 } + la1, ln1, la2, ln2 := toRad(lat1), toRad(lng1), toRad(lat2), toRad(lng2) + h := math.Sin((la2-la1)/2)*math.Sin((la2-la1)/2) + + math.Cos(la1)*math.Cos(la2)*math.Sin((ln2-ln1)/2)*math.Sin((ln2-ln1)/2) + return 2 * earthM * math.Asin(math.Min(1, math.Sqrt(math.Max(0, h)))) +} + +// ComputeProgress derives straight-line progress. Without a fix the +// trip sits at the origin (nothing done); without route coords there +// is no progress at all. Pure: no I/O, deterministic. +func ComputeProgress(oLat, oLng, dLat, dLng *float64, cur *Checkpoint) *Progress { + if oLat == nil || oLng == nil || dLat == nil || dLng == nil { + return nil + } + total := haversineM(*oLat, *oLng, *dLat, *dLng) + if cur == nil { + return &Progress{TotalM: total, DoneM: 0, LeftM: total} + } + left := haversineM(cur.Lat, cur.Lng, *dLat, *dLng) + done := math.Max(0, total-left) + return &Progress{TotalM: total, DoneM: done, LeftM: left} +} + +// ComputeRange grades remaining energy against remaining distance at +// the measured efficiency. Nil energy or efficiency degrades to +// unknown; the 15% buffer separates ok from attention. Pure. +func ComputeRange(haveWh *float64, leftM float64, effWhKm *float64) *Range { + r := &Range{HaveWh: haveWh, EffWhKm: effWhKm, Verdict: ItemUnknown} + if haveWh == nil || effWhKm == nil || *effWhKm <= 0 { + return r + } + need := leftM / 1000 * *effWhKm + r.NeedWh = &need + switch { + case *haveWh >= need*rangeBuffer: + r.Verdict = ItemOK + case *haveWh >= need: + r.Verdict = ItemAttention + default: + r.Verdict = ItemAction + } + return r +} + +// ParseNextStop reads the head stop from a saved plan payload. Only +// stop_scores and replan plans carry ranked stops; anything else +// yields nil. Pure: never errors, never panics on malformed JSON. +func ParseNextStop(raw json.RawMessage) *NextStop { + var plan struct { + Kind string `json:"kind"` + Stops []struct { + Site string `json:"site"` + WaitS *float64 `json:"wait_s"` + } `json:"stops"` + } + if err := json.Unmarshal(raw, &plan); err != nil { + return nil + } + if plan.Kind != "stop_scores" && plan.Kind != "replan" { + return nil + } + if len(plan.Stops) == 0 || plan.Stops[0].Site == "" { + return nil + } + return &NextStop{Site: plan.Stops[0].Site, WaitS: plan.Stops[0].WaitS} +} + +// TrailStore is the checkpoint/efficiency/route-history port. *Store +// satisfies it. +type TrailStore interface { + AppendCheckpoint(ctx context.Context, sessionID int64, in NewCheckpoint) (*Checkpoint, error) + LatestCheckpoint(ctx context.Context, sessionID int64) (*Checkpoint, error) + Trail(ctx context.Context, sessionID int64, limit int) ([]*Checkpoint, error) + VehicleEfficiency(ctx context.Context, vehicleID int64) (float64, bool, error) + RouteLegs(ctx context.Context, vehicleID int64, origin, dest string, limit int) ([]RouteLeg, error) +} + +// LiveHandler serves the live trip session. Stateless beyond +// constructor inputs; safe for concurrent use. +type LiveHandler struct { + store SessionStore + trail TrailStore + live LiveSignals + now func() time.Time +} + +// NewLiveHandler wires the handler. Panics on nil inputs (fail-fast +// wiring contract, matching sibling handlers). +func NewLiveHandler(store SessionStore, trail TrailStore, live LiveSignals) *LiveHandler { + if store == nil || trail == nil || live == nil { + panic("journey: nil dependency") + } + return &LiveHandler{store: store, trail: trail, live: live, now: time.Now} +} + +type checkpointRequest struct { + RecordedAt *time.Time `json:"recorded_at"` + Lat *float64 `json:"lat"` + Lng *float64 `json:"lng"` + SocPct *float64 `json:"soc_pct"` + OdometerM *float64 `json:"odometer_m"` +} + +// Append serves POST /journey/sessions/{id}/checkpoints: snapshot one +// trail point. Missing fields backfill from live telemetry, so a bare +// ping still records the car; without either source the field stays +// null — except position, which is required. Only live (active or +// paused) sessions accept checkpoints. +func (h *LiveHandler) Append(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + var req checkpointRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + now := h.now().UTC() + at := now + if req.RecordedAt != nil { + at = req.RecordedAt.UTC() + if at.After(now.Add(futureTolerance)) { + httpx.WriteError(w, http.StatusBadRequest, "recorded_at is too far in the future") + return + } + } + if req.SocPct != nil && (*req.SocPct < 0 || *req.SocPct > 100) { + httpx.WriteError(w, http.StatusBadRequest, "soc_pct must be 0..100") + return + } + if req.OdometerM != nil && *req.OdometerM < 0 { + httpx.WriteError(w, http.StatusBadRequest, "odometer_m must be non-negative") + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + if session.Status != StatusActive && session.Status != StatusPaused { + httpx.WriteError(w, http.StatusConflict, "checkpoints need a live (active or paused) journey") + return + } + lat, lng, soc, odo := req.Lat, req.Lng, req.SocPct, req.OdometerM + backfill := func(dst **float64, name string) error { + if *dst != nil { + return nil + } + v, err := signalFloat(h.live, ctx, session.VehicleID, name) + if err != nil { + return err + } + *dst = v + return nil + } + for _, b := range []struct { + dst **float64 + name string + }{ + {&lat, "LocationLatitude"}, + {&lng, "LocationLongitude"}, + {&soc, "Soc"}, + {&odo, "Odometer"}, + } { + if err := backfill(b.dst, b.name); err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: checkpoint backfill failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read vehicle state") + return + } + } + if lat == nil || lng == nil { + httpx.WriteError(w, http.StatusBadRequest, "position required: post lat/lng or wake the vehicle") + return + } + cp, err := h.trail.AppendCheckpoint(ctx, id, NewCheckpoint{ + RecordedAt: at, Lat: *lat, Lng: *lng, SocPct: soc, OdometerM: odo, + }) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: append checkpoint failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save checkpoint") + return + } + httpx.WriteJSON(w, http.StatusCreated, cp) +} + +// View serves GET /journey/sessions/{id}/live: the glanceable live +// snapshot — session, latest fix, trail, progress, range, and the head +// of the latest scored plan. +func (h *LiveHandler) View(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + latest, err := h.trail.LatestCheckpoint(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: latest checkpoint failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + trail, err := h.trail.Trail(ctx, id, 20) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: trail failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + progress := ComputeProgress(session.OriginLat, session.OriginLng, session.DestLat, session.DestLng, latest) + rng, err := h.rangeFor(ctx, session, progress) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: range failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read vehicle state") + return + } + next, err := h.nextStop(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: next stop failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read plan") + return + } + httpx.WriteJSON(w, http.StatusOK, LiveView{ + Session: session, Latest: latest, Trail: trail, + Progress: progress, Range: rng, Next: next, + Evidence: liveEvidence(latest, progress, rng, next), + }) +} + +func (h *LiveHandler) rangeFor(ctx context.Context, session *Session, progress *Progress) (*Range, error) { + if progress == nil { + return &Range{Verdict: ItemUnknown}, nil + } + // EnergyRemaining is current usable kWh; Soc × nominal is NOT used + // as a substitute — a guess here would misgrade range. + energyKWh, err := signalFloat(h.live, ctx, session.VehicleID, "EnergyRemaining") + if err != nil { + return nil, err + } + var haveWh *float64 + if energyKWh != nil { + have := *energyKWh * 1000 + haveWh = &have + } + eff, ok, err := h.trail.VehicleEfficiency(ctx, session.VehicleID) + if err != nil { + return nil, err + } + var effPtr *float64 + if ok { + effPtr = &eff + } + return ComputeRange(haveWh, progress.LeftM, effPtr), nil +} + +func (h *LiveHandler) nextStop(ctx context.Context, sessionID int64) (*NextStop, error) { + plans, err := h.store.ListPlans(ctx, sessionID) + if err != nil { + return nil, err + } + // Newest ranked plan wins, by version — not slice order, which + // stores are free to choose. + var best *PlanVersion + for _, p := range plans { + if ParseNextStop(p.Plan) == nil { + continue + } + if best == nil || p.Version > best.Version { + best = p + } + } + if best == nil { + return nil, nil + } + return ParseNextStop(best.Plan), nil +} + +func liveEvidence(latest *Checkpoint, progress *Progress, rng *Range, next *NextStop) []string { + out := []string{} + if latest == nil { + out = append(out, "no fixes yet — check in to start the trail") + } else { + out = append(out, "last fix "+latest.RecordedAt.Format("15:04:05")) + } + if progress != nil && progress.TotalM > 0 { + out = append(out, fmt.Sprintf("straight-line progress %.0f%%", progress.DoneM/max1(progress.TotalM)*100)) + } else { + out = append(out, "route coordinates missing — progress unavailable") + } + switch rng.Verdict { + case ItemOK: + out = append(out, "energy covers the remainder with buffer") + case ItemAttention: + out = append(out, "energy covers the remainder without buffer") + case ItemAction: + out = append(out, "energy short of the remainder — charge soon") + default: + out = append(out, "range unknown: needs live energy + drive history") + } + if next != nil { + out = append(out, "next stop "+next.Site) + } else { + out = append(out, "no scored plan — next stop unset") + } + return out +} + +func max1(v float64) float64 { + if v <= 0 { + return 1 + } + return v +} + +// Compile-time port assertions. +var ( + _ TrailStore = (*Store)(nil) +) diff --git a/internal/api/journey/live_test.go b/internal/api/journey/live_test.go new file mode 100644 index 000000000..e3ef253eb --- /dev/null +++ b/internal/api/journey/live_test.go @@ -0,0 +1,322 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +type fakeTrail struct { + points []*Checkpoint + eff float64 + hasEff bool + legs []RouteLeg + err error +} + +func (f *fakeTrail) AppendCheckpoint(_ context.Context, sessionID int64, in NewCheckpoint) (*Checkpoint, error) { + if f.err != nil { + return nil, f.err + } + cp := &Checkpoint{ + ID: int64(len(f.points) + 1), SessionID: sessionID, + RecordedAt: in.RecordedAt, Lat: in.Lat, Lng: in.Lng, + SocPct: in.SocPct, OdometerM: in.OdometerM, + } + f.points = append(f.points, cp) + return cp, nil +} + +func (f *fakeTrail) LatestCheckpoint(_ context.Context, _ int64) (*Checkpoint, error) { + if f.err != nil { + return nil, f.err + } + if len(f.points) == 0 { + return nil, nil + } + return f.points[len(f.points)-1], nil +} + +func (f *fakeTrail) Trail(_ context.Context, _ int64, limit int) ([]*Checkpoint, error) { + if f.err != nil { + return nil, f.err + } + out := f.points + if len(out) > limit { + out = out[len(out)-limit:] + } + return out, nil +} + +func (f *fakeTrail) VehicleEfficiency(_ context.Context, _ int64) (float64, bool, error) { + return f.eff, f.hasEff, f.err +} + +func (f *fakeTrail) RouteLegs(_ context.Context, _ int64, _, _ string, _ int) ([]RouteLeg, error) { + if f.err != nil { + return nil, f.err + } + return f.legs, nil +} + +var _ TrailStore = (*fakeTrail)(nil) + +func liveSession() *Session { + return &Session{ + ID: 1, VehicleID: 7, Name: "denver run", + OriginLat: fptr(39.7392), OriginLng: fptr(-104.9903), + DestLat: fptr(39.0997), DestLng: fptr(-94.5786), + Status: StatusActive, + } +} + +func liveRequest(method, url, id, body string) *http.Request { + var rdr *strings.Reader + if body == "" { + rdr = strings.NewReader("") + } else { + rdr = strings.NewReader(body) + } + req := httptest.NewRequest(method, url, rdr) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", id) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestComputeProgress(t *testing.T) { + oLat, oLng := 39.7392, -104.9903 + dLat, dLng := 39.0997, -94.5786 + if p := ComputeProgress(nil, &oLng, &dLat, &dLng, nil); p != nil { + t.Fatalf("missing origin = %+v, want nil", p) + } + p := ComputeProgress(&oLat, &oLng, &dLat, &dLng, nil) + if p == nil || p.DoneM != 0 || p.LeftM != p.TotalM || p.TotalM <= 0 { + t.Fatalf("no fix = %+v, want done=0 left=total", p) + } + // ~900km Denver→KC; a fix at the destination must nearly zero left. + at := &Checkpoint{Lat: dLat, Lng: dLng} + p = ComputeProgress(&oLat, &oLng, &dLat, &dLng, at) + if p.LeftM > 1000 || p.DoneM <= 0 { + t.Fatalf("at dest = %+v, want left≈0", p) + } + if p.TotalM < 800_000 || p.TotalM > 1_000_000 { + t.Fatalf("total = %f, want ~900km", p.TotalM) + } +} + +func TestComputeRange(t *testing.T) { + cases := []struct { + name string + have *float64 + eff *float64 + want string + }{ + {"ok with buffer", fptr(30000), fptr(180), ItemOK}, // 30kWh vs 18kWh need + {"attention no buffer", fptr(19000), fptr(180), ItemAttention}, // covers 100km need, not buffer + {"action short", fptr(5000), fptr(180), ItemAction}, + {"unknown no energy", nil, fptr(180), ItemUnknown}, + {"unknown no eff", fptr(30000), nil, ItemUnknown}, + {"unknown zero eff", fptr(30000), fptr(0), ItemUnknown}, + } + for _, c := range cases { + r := ComputeRange(c.have, 100_000, c.eff) + if r.Verdict != c.want { + t.Errorf("%s: verdict = %s, want %s", c.name, r.Verdict, c.want) + } + } + r := ComputeRange(fptr(30000), 100_000, fptr(180)) + if r.NeedWh == nil || *r.NeedWh < 17999 || *r.NeedWh > 18001 { + t.Fatalf("need = %v, want 18000", r.NeedWh) + } +} + +func TestParseNextStop(t *testing.T) { + raw := json.RawMessage(`{"kind":"stop_scores","stops":[{"site":"Flagler SC","wait_s":300}]}`) + next := ParseNextStop(raw) + if next == nil || next.Site != "Flagler SC" || next.WaitS == nil || *next.WaitS != 300 { + t.Fatalf("next = %+v", next) + } + for _, raw := range []json.RawMessage{ + json.RawMessage(`{"kind":"departure","stops":[{"site":"x"}]}`), + json.RawMessage(`{"kind":"stop_scores","stops":[]}`), + json.RawMessage(`{"kind":"stop_scores","stops":[{"site":""}]}`), + json.RawMessage(`not json`), + nil, + } { + if next := ParseNextStop(raw); next != nil { + t.Fatalf("raw %q: next = %+v, want nil", string(raw), next) + } + } +} + +func TestAppendCheckpoint(t *testing.T) { + now := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + f := newFakeStore() + f.sessions[1] = liveSession() + tr := &fakeTrail{} + h := NewLiveHandler(f, tr, &fakeLive{}) + h.now = func() time.Time { return now } + rec := httptest.NewRecorder() + h.Append(rec, liveRequest(http.MethodPost, "/journey/sessions/1/checkpoints", "1", + `{"lat":39.5,"lng":-100.0,"soc_pct":71}`)) + if rec.Code != http.StatusCreated { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var cp Checkpoint + if err := json.Unmarshal(rec.Body.Bytes(), &cp); err != nil { + t.Fatal(err) + } + if cp.Lat != 39.5 || cp.SocPct == nil || *cp.SocPct != 71 || !cp.RecordedAt.Equal(now) { + t.Fatalf("checkpoint = %+v", cp) + } +} + +func TestAppendCheckpointBackfillsLive(t *testing.T) { + now := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + f := newFakeStore() + f.sessions[1] = liveSession() + tr := &fakeTrail{} + h := NewLiveHandler(f, tr, &fakeLive{values: map[string]signal.SignalValue{ + "LocationLatitude": 39.5, "LocationLongitude": -100.0, "Soc": 66.0, "Odometer": 12345.0, + }}) + h.now = func() time.Time { return now } + rec := httptest.NewRecorder() + h.Append(rec, liveRequest(http.MethodPost, "/journey/sessions/1/checkpoints", "1", `{}`)) + if rec.Code != http.StatusCreated { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var cp Checkpoint + if err := json.Unmarshal(rec.Body.Bytes(), &cp); err != nil { + t.Fatal(err) + } + if cp.Lat != 39.5 || cp.Lng != -100.0 || cp.SocPct == nil || *cp.SocPct != 66 || cp.OdometerM == nil { + t.Fatalf("backfilled = %+v", cp) + } +} + +func TestAppendCheckpointErrors(t *testing.T) { + now := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + setup := func() (*fakeStore, *fakeTrail) { + f := newFakeStore() + f.sessions[1] = liveSession() + return f, &fakeTrail{} + } + cases := []struct { + name string + id string + body string + live *fakeLive + want int + mut func(*fakeStore) + }{ + {"bad id", "abc", `{"lat":1,"lng":1}`, &fakeLive{}, http.StatusBadRequest, nil}, + {"bad body", "1", `{oops`, &fakeLive{}, http.StatusBadRequest, nil}, + {"future", "1", `{"lat":1,"lng":1,"recorded_at":"2026-09-14T11:00:00Z"}`, &fakeLive{}, http.StatusBadRequest, nil}, + {"bad soc", "1", `{"lat":1,"lng":1,"soc_pct":101}`, &fakeLive{}, http.StatusBadRequest, nil}, + {"bad odo", "1", `{"lat":1,"lng":1,"odometer_m":-5}`, &fakeLive{}, http.StatusBadRequest, nil}, + {"no position", "1", `{}`, &fakeLive{}, http.StatusBadRequest, nil}, + {"missing", "9", `{"lat":1,"lng":1}`, &fakeLive{}, http.StatusNotFound, nil}, + {"planned rejects", "1", `{"lat":1,"lng":1}`, &fakeLive{}, http.StatusConflict, func(f *fakeStore) { + s := liveSession() + s.Status = StatusPlanned + f.sessions[1] = s + }}, + {"live down", "1", `{}`, &fakeLive{err: errors.New("redis down")}, http.StatusInternalServerError, nil}, + } + for _, c := range cases { + f, tr := setup() + if c.mut != nil { + c.mut(f) + } + h := NewLiveHandler(f, tr, c.live) + h.now = func() time.Time { return now } + rec := httptest.NewRecorder() + h.Append(rec, liveRequest(http.MethodPost, "/journey/sessions/1/checkpoints", c.id, c.body)) + if rec.Code != c.want { + t.Errorf("%s: code = %d, want %d (%s)", c.name, rec.Code, c.want, rec.Body.String()) + } + } +} + +func TestLiveView(t *testing.T) { + f := newFakeStore() + f.sessions[1] = liveSession() + plan := json.RawMessage(`{"kind":"stop_scores","stops":[{"site":"Flagler SC","wait_s":300}]}`) + f.plans[1] = []*PlanVersion{{ID: 1, SessionID: 1, Version: 1, Plan: plan}} + tr := &fakeTrail{eff: 180, hasEff: true, points: []*Checkpoint{ + {ID: 1, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC), Lat: 39.7392, Lng: -104.9903}, + {ID: 2, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC), Lat: 39.5, Lng: -100.0, SocPct: fptr(71)}, + }} + h := NewLiveHandler(f, tr, &fakeLive{values: map[string]signal.SignalValue{"EnergyRemaining": 60.0}}) + rec := httptest.NewRecorder() + h.View(rec, liveRequest(http.MethodGet, "/journey/sessions/1/live", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var view LiveView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + if view.Session == nil || view.Session.ID != 1 { + t.Fatalf("session = %+v", view.Session) + } + if view.Latest == nil || view.Latest.ID != 2 || len(view.Trail) != 2 { + t.Fatalf("trail latest=%+v len=%d", view.Latest, len(view.Trail)) + } + if view.Progress == nil || view.Progress.DoneM <= 0 || view.Progress.LeftM <= 0 { + t.Fatalf("progress = %+v", view.Progress) + } + if view.Range == nil || view.Range.Verdict == "" { + t.Fatalf("range = %+v", view.Range) + } + if view.Next == nil || view.Next.Site != "Flagler SC" { + t.Fatalf("next = %+v", view.Next) + } + if len(view.Evidence) != 4 { + t.Fatalf("evidence = %v, want 4 lines", view.Evidence) + } +} + +func TestLiveViewDegraded(t *testing.T) { + f := newFakeStore() + s := liveSession() + s.OriginLat, s.OriginLng, s.DestLat, s.DestLng = nil, nil, nil, nil + f.sessions[1] = s + h := NewLiveHandler(f, &fakeTrail{}, &fakeLive{}) + rec := httptest.NewRecorder() + h.View(rec, liveRequest(http.MethodGet, "/journey/sessions/1/live", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var view LiveView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatal(err) + } + if view.Progress != nil || view.Latest != nil || view.Next != nil { + t.Fatalf("degraded view should omit progress/latest/next: %+v", view) + } + if view.Range == nil || view.Range.Verdict != ItemUnknown { + t.Fatalf("range = %+v, want unknown", view.Range) + } + if len(view.Evidence) != 4 { + t.Fatalf("evidence = %v, want 4 lines", view.Evidence) + } +} + +func TestLiveViewNotFound(t *testing.T) { + h := NewLiveHandler(newFakeStore(), &fakeTrail{}, &fakeLive{}) + rec := httptest.NewRecorder() + h.View(rec, liveRequest(http.MethodGet, "/journey/sessions/9/live", "9", "")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} diff --git a/internal/api/journey/nudge.go b/internal/api/journey/nudge.go new file mode 100644 index 000000000..642d60eaa --- /dev/null +++ b/internal/api/journey/nudge.go @@ -0,0 +1,173 @@ +package journey + +import ( + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Nudge window: the recommended slot reads as "now" from 15 minutes +// before (wheels-up prep) to 30 minutes after (still the same calm +// hour). Outside it the driver waits for the slot — or missed it, in +// which case the honest answer is still "go now". +const ( + nudgeEarly = 15 * time.Minute + nudgeLate = 30 * time.Minute +) + +// Nudge verdicts. +const ( + NudgeLeaveNow = "leave_now" + NudgeWait = "wait" + NudgeDelay = "delay" + NudgeUnknown = "unknown" +) + +// Nudge is the GET response: the departure verdict plus the blockers +// and slot behind it. +type Nudge struct { + SessionID int64 `json:"session_id"` + Verdict string `json:"verdict"` // leave_now, wait, delay, unknown + SlotAt *time.Time `json:"slot_at"` + Blockers []Item `json:"blockers"` + Evidence []string `json:"evidence"` +} + +// NudgeVerdict folds the recommended calm slot and the readiness +// blockers into one verdict. Action-level blockers always win — a +// calm sky does not fix a flat. Attention-level items never block. +// Pure: no I/O, deterministic. +func NudgeVerdict(now time.Time, recommended *time.Time, blockers []Item) string { + if len(blockers) > 0 { + return NudgeWait + } + if recommended == nil { + return NudgeDelay + } + if recommended.After(now.Add(nudgeLate)) { + return NudgeWait + } + return NudgeLeaveNow +} + +// Blockers filters a run to action-level items. Nil run yields nil — +// no run is not a blocker, it is flagged separately in evidence so a +// fresh trip does not read as broken. Pure. +func Blockers(run *Run) []Item { + if run == nil { + return nil + } + out := []Item{} + for _, item := range run.Items { + if item.Status == ItemAction { + out = append(out, item) + } + } + return out +} + +// NudgeHandler serves the leave-now nudge. Stateless beyond +// constructor inputs; safe for concurrent use. +type NudgeHandler struct { + store SessionStore + meteo Meteo + runs RunStore + now func() time.Time +} + +// NewNudgeHandler wires the handler. Panics on nil inputs (fail-fast +// wiring contract, matching sibling handlers). +func NewNudgeHandler(store SessionStore, meteo Meteo, runs RunStore) *NudgeHandler { + if store == nil || meteo == nil || runs == nil { + panic("journey: nil dependency") + } + return &NudgeHandler{store: store, meteo: meteo, runs: runs, now: time.Now} +} + +// Monitor serves GET /journey/sessions/{id}/nudge: leave now, wait for +// the slot, or delay — from the calm-hour ranking plus the readiness +// blockers. Planned sessions only: once rolling, the live panels own +// the drive. +func (h *NudgeHandler) Monitor(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + if session.Status != StatusPlanned { + httpx.WriteError(w, http.StatusConflict, "the nudge is for planned journeys — this one is "+session.Status) + return + } + now := h.now().UTC() + run, err := h.runs.LatestChecklistRun(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: latest checklist failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read checklist") + return + } + blockers := Blockers(run) + var slot *time.Time + if session.OriginLat != nil && session.OriginLng != nil { + forecast, err := h.meteo.Fetch(ctx, *session.OriginLat, *session.OriginLng) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: forecast fetch failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read forecast") + return + } + _, slot = RankDepartureSlots(forecast, now, now.Add(defaultDepartureHorizon)) + } + verdict := NudgeUnknown + if session.OriginLat != nil && session.OriginLng != nil { + verdict = NudgeVerdict(now, slot, blockers) + } + out := Nudge{SessionID: id, Verdict: verdict, SlotAt: slot, Blockers: blockers} + out.Evidence = nudgeEvidence(now, slot, blockers, run == nil, session.OriginLat == nil) + httpx.WriteJSON(w, http.StatusOK, out) +} + +func nudgeEvidence(now time.Time, slot *time.Time, blockers []Item, noRun, noCoords bool) []string { + out := []string{} + if noCoords { + out = append(out, "origin coordinates missing — no forecast to rank") + return out + } + if len(blockers) > 0 { + out = append(out, blockerLine(blockers)) + } + if noRun { + out = append(out, "no checklist run yet — blockers may hide") + } + switch { + case slot == nil: + out = append(out, "no calm hour in the next 12 h — delay") + case slot.After(now.Add(nudgeLate)): + out = append(out, "calm window opens "+slot.Format("Mon 15:04")) + case slot.Before(now.Add(-nudgeEarly)): + out = append(out, "calm slot passed — go now on the next calm hour") + default: + out = append(out, "in the calm window now") + } + return out +} + +func blockerLine(blockers []Item) string { + if len(blockers) == 1 { + return "1 blocker: " + blockers[0].Detail + } + return strconv.Itoa(len(blockers)) + " blockers, starting with: " + blockers[0].Detail +} diff --git a/internal/api/journey/nudge_test.go b/internal/api/journey/nudge_test.go new file mode 100644 index 000000000..905494934 --- /dev/null +++ b/internal/api/journey/nudge_test.go @@ -0,0 +1,207 @@ +package journey + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/stormguard" +) + +func TestNudgeVerdict(t *testing.T) { + now := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + in := now + future := now.Add(3 * time.Hour) + recent := now.Add(-20 * time.Minute) + blockers := []Item{{Key: "tire_pressure", Status: ItemAction, Detail: "flat"}} + cases := []struct { + name string + slot *time.Time + items []Item + want string + }{ + {"blockers win over a live slot", &in, blockers, NudgeWait}, + {"no slot delays", nil, nil, NudgeDelay}, + {"future slot waits", &future, nil, NudgeWait}, + {"live slot leaves", &in, nil, NudgeLeaveNow}, + {"missed slot still leaves", &recent, nil, NudgeLeaveNow}, + } + for _, c := range cases { + if got := NudgeVerdict(now, c.slot, c.items); got != c.want { + t.Errorf("%s: verdict = %s, want %s", c.name, got, c.want) + } + } + if got := NudgeVerdict(now, &in, nil); got != NudgeLeaveNow { + t.Fatalf("clean leaves = %s", got) + } +} + +func TestBlockers(t *testing.T) { + if b := Blockers(nil); b != nil { + t.Fatalf("nil = %+v, want nil", b) + } + run := &Run{Items: []Item{ + {Key: "a", Status: ItemOK}, + {Key: "b", Status: ItemAction, Detail: "fix b"}, + {Key: "c", Status: "attention"}, + {Key: "d", Status: ItemAction, Detail: "fix d"}, + }} + b := Blockers(run) + if len(b) != 2 || b[0].Key != "b" || b[1].Key != "d" { + t.Fatalf("blockers = %+v, want b+d", b) + } + if b := Blockers(&Run{}); len(b) != 0 { + t.Fatalf("empty = %+v, want empty", b) + } +} + +func nudgeSession() *Session { + s := liveSession() + s.Status = StatusPlanned + s.StartedAt = nil + return s +} + +func TestMonitor(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + f := newFakeStore() + f.sessions[1] = nudgeSession() + runs := &fakeRuns{runs: map[int64][]*Run{1: {{ + ID: 1, SessionID: 1, RunAt: base.Add(-time.Hour), + Items: []Item{{Key: "a", Status: ItemOK}, {Key: "b", Status: ItemOK}}, + }}}} + h := NewNudgeHandler(f, &fakeMeteo{forecast: departForecast(base)}, runs) + h.now = func() time.Time { return base } + rec := httptest.NewRecorder() + h.Monitor(rec, liveRequest(http.MethodGet, "/journey/sessions/1/nudge", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Nudge + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Verdict != NudgeLeaveNow || got.SlotAt == nil || !got.SlotAt.Equal(base) { + t.Fatalf("nudge = %+v, want leave_now @ base", got) + } + if len(got.Blockers) != 0 { + t.Fatalf("blockers = %+v, want none", got.Blockers) + } + if len(got.Evidence) != 1 { + t.Fatalf("evidence = %v, want 1 line", got.Evidence) + } +} + +func TestMonitorWait(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + f := newFakeStore() + f.sessions[1] = nudgeSession() + runs := &fakeRuns{runs: map[int64][]*Run{1: {{ + ID: 1, SessionID: 1, RunAt: base.Add(-time.Hour), + Items: []Item{{Key: "tire_pressure", Status: ItemAction, Detail: "FR at 2.6 bar"}}, + }}}} + h := NewNudgeHandler(f, &fakeMeteo{forecast: departForecast(base)}, runs) + h.now = func() time.Time { return base } + rec := httptest.NewRecorder() + h.Monitor(rec, liveRequest(http.MethodGet, "/journey/sessions/1/nudge", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Nudge + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Verdict != NudgeWait || len(got.Blockers) != 1 { + t.Fatalf("nudge = %+v, want wait + 1 blocker", got) + } + if len(got.Evidence) != 2 { + t.Fatalf("evidence = %v, want 2 lines", got.Evidence) + } +} + +func TestMonitorDelay(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + f := newFakeStore() + f.sessions[1] = nudgeSession() + warn := &stormguard.Forecast{ + Times: []time.Time{base, base.Add(time.Hour)}, + Weather: []int{95, 99}, + WindGustMS: []float64{9, 9}, + } + h := NewNudgeHandler(f, &fakeMeteo{forecast: warn}, &fakeRuns{runs: map[int64][]*Run{}}) + h.now = func() time.Time { return base } + rec := httptest.NewRecorder() + h.Monitor(rec, liveRequest(http.MethodGet, "/journey/sessions/1/nudge", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Nudge + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Verdict != NudgeDelay || got.SlotAt != nil { + t.Fatalf("nudge = %+v, want delay without slot", got) + } +} + +func TestMonitorUnknown(t *testing.T) { + f := newFakeStore() + s := nudgeSession() + s.OriginLat, s.OriginLng = nil, nil + f.sessions[1] = s + h := NewNudgeHandler(f, &fakeMeteo{}, &fakeRuns{runs: map[int64][]*Run{}}) + rec := httptest.NewRecorder() + h.Monitor(rec, liveRequest(http.MethodGet, "/journey/sessions/1/nudge", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Nudge + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Verdict != NudgeUnknown { + t.Fatalf("verdict = %s, want unknown", got.Verdict) + } +} + +func TestMonitorErrors(t *testing.T) { + base := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + newHandler := func(f *fakeStore, m *fakeMeteo) *NudgeHandler { + h := NewNudgeHandler(f, m, &fakeRuns{runs: map[int64][]*Run{}}) + h.now = func() time.Time { return base } + return h + } + f := newFakeStore() + f.sessions[1] = nudgeSession() + cases := []struct { + name string + id string + h *NudgeHandler + want int + }{ + {"bad id", "abc", newHandler(f, &fakeMeteo{forecast: departForecast(base)}), http.StatusBadRequest}, + {"missing", "9", newHandler(f, &fakeMeteo{forecast: departForecast(base)}), http.StatusNotFound}, + {"meteo down", "1", newHandler(f, &fakeMeteo{err: errors.New("meteo down")}), http.StatusInternalServerError}, + } + for _, c := range cases { + rec := httptest.NewRecorder() + c.h.Monitor(rec, liveRequest(http.MethodGet, "/journey/sessions/1/nudge", c.id, "")) + if rec.Code != c.want { + t.Errorf("%s: code = %d, want %d (%s)", c.name, rec.Code, c.want, rec.Body.String()) + } + } +} + +func TestMonitorConflict(t *testing.T) { + f := newFakeStore() + f.sessions[1] = liveSession() // active + h := NewNudgeHandler(f, &fakeMeteo{}, &fakeRuns{runs: map[int64][]*Run{}}) + rec := httptest.NewRecorder() + h.Monitor(rec, liveRequest(http.MethodGet, "/journey/sessions/1/nudge", "1", "")) + if rec.Code != http.StatusConflict { + t.Fatalf("code = %d, want 409", rec.Code) + } +} diff --git a/internal/api/journey/replan.go b/internal/api/journey/replan.go new file mode 100644 index 000000000..20c672e38 --- /dev/null +++ b/internal/api/journey/replan.go @@ -0,0 +1,309 @@ +package journey + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Deviation thresholds in SI meters, measured as cross-track distance +// from the straight origin→destination corridor (the documented proxy +// until turn-by-turn legs exist). GPS noise is ~10 m and a highway +// corridor is a few hundred meters wide, so 2 km means a deliberate +// detour has started and 10 km means the plan no longer applies. +const ( + driftM = 2000 + offRouteM = 10000 +) + +// Deviation verdicts. +const ( + DeviationOnTrack = "on_track" + DeviationDrifted = "drifted" + DeviationOffRoute = "off_route" + DeviationUnknown = "unknown" +) + +// Deviation is the pure assessment result. +type Deviation struct { + DeviationM *float64 `json:"deviation_m"` + Verdict string `json:"verdict"` // on_track, drifted, off_route, unknown +} + +// Assessment is the GET response: deviation plus the fix it was +// measured from. +type Assessment struct { + SessionID int64 `json:"session_id"` + Deviation *Deviation `json:"deviation"` + Latest *Checkpoint `json:"latest"` + Evidence []string `json:"evidence"` +} + +// AssessDeviation grades the latest fix against the straight-line +// corridor. Missing route coordinates or no fix degrades to unknown. +// Pure: no I/O, deterministic. +func AssessDeviation(oLat, oLng, dLat, dLng *float64, fix *Checkpoint) *Deviation { + if oLat == nil || oLng == nil || dLat == nil || dLng == nil || fix == nil { + return &Deviation{DeviationM: nil, Verdict: DeviationUnknown} + } + dev := corridorDeviationM(*oLat, *oLng, *dLat, *dLng, fix.Lat, fix.Lng) + verdict := DeviationOnTrack + switch { + case dev >= offRouteM: + verdict = DeviationOffRoute + case dev >= driftM: + verdict = DeviationDrifted + } + return &Deviation{DeviationM: &dev, Verdict: verdict} +} + +// savedCandidates reads the rescorable inputs back from a saved plan: +// the candidate echo ScoreStops/Rescore persist plus the charge need. +// Arrivals are deliberately NOT echoed — a replan always predicts waits +// as of now. Pure: never errors, nil-ok on anything unusable. +func savedCandidates(raw json.RawMessage) (cands []Candidate, energyWh float64, ok bool) { + var plan struct { + Kind string `json:"kind"` + EnergyWh float64 `json:"energy_wh"` + Candidates []struct { + Site string `json:"site"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + } `json:"candidates"` + } + if err := json.Unmarshal(raw, &plan); err != nil { + return nil, 0, false + } + if plan.Kind != "stop_scores" && plan.Kind != "replan" { + return nil, 0, false + } + if plan.EnergyWh <= 0 || len(plan.Candidates) == 0 || len(plan.Candidates) > maxCandidates { + return nil, 0, false + } + for _, c := range plan.Candidates { + if c.Site == "" || c.Lat < -90 || c.Lat > 90 || c.Lng < -180 || c.Lng > 180 { + return nil, 0, false + } + cands = append(cands, Candidate{Site: c.Site, Lat: c.Lat, Lng: c.Lng}) + } + return cands, plan.EnergyWh, true +} + +// latestScoredPlan picks the newest rescorable plan by version (not by +// slice order — stores are free to order either way). +func latestScoredPlan(plans []*PlanVersion) ([]Candidate, float64, bool) { + best := -1 + for i, p := range plans { + if _, _, ok := savedCandidates(p.Plan); ok && (best < 0 || p.Version > plans[best].Version) { + best = i + } + } + if best < 0 { + return nil, 0, false + } + return savedCandidates(plans[best].Plan) +} + +// ReplanHandler serves deviation assessment and one-click rescoring. +// Stateless beyond constructor inputs; safe for concurrent use. +type ReplanHandler struct { + store SessionStore + trail TrailStore + signals SignalStore + waits WaitStore + now func() time.Time +} + +// NewReplanHandler wires the handler. Panics on nil inputs (fail-fast +// wiring contract, matching sibling handlers). +func NewReplanHandler(store SessionStore, trail TrailStore, signals SignalStore, waits WaitStore) *ReplanHandler { + if store == nil || trail == nil || signals == nil || waits == nil { + panic("journey: nil dependency") + } + return &ReplanHandler{store: store, trail: trail, signals: signals, waits: waits, now: time.Now} +} + +// Assess serves GET /journey/sessions/{id}/replan: how far the latest +// fix sits off the planned corridor and whether a rescore is advised. +func (h *ReplanHandler) Assess(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + latest, err := h.trail.LatestCheckpoint(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: latest checkpoint failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + dev := AssessDeviation(session.OriginLat, session.OriginLng, session.DestLat, session.DestLng, latest) + httpx.WriteJSON(w, http.StatusOK, Assessment{ + SessionID: id, Deviation: dev, Latest: latest, + Evidence: deviationEvidence(dev, latest), + }) +} + +func deviationEvidence(dev *Deviation, latest *Checkpoint) []string { + if latest == nil { + return []string{"no fixes yet — check in to measure deviation"} + } + if dev.DeviationM == nil { + return []string{"route coordinates missing — deviation unavailable"} + } + km := *dev.DeviationM / 1000 + out := []string{formatKm("off the straight-line corridor by ", km)} + switch dev.Verdict { + case DeviationOffRoute: + out = append(out, "off route — rescore from the current position") + case DeviationDrifted: + out = append(out, "drifting — watch the next fix or rescore now") + default: + out = append(out, "on track — the saved plan still applies") + } + return out +} + +func formatKm(prefix string, km float64) string { + if km < 10 { + return prefix + strconv.FormatFloat(km, 'f', 1, 64) + " km" + } + return prefix + strconv.FormatFloat(km, 'f', 0, 64) + " km" +} + +type replanRequest struct { + EnergyWh *float64 `json:"energy_wh"` +} + +// Rescore serves POST /journey/sessions/{id}/replan: re-rank the saved +// candidate set from the latest fix with waits predicted as of now, +// then persist the ranking as a new plan version. Only live (active or +// paused) sessions replan; energy_wh overrides the saved charge need +// when positive. +func (h *ReplanHandler) Rescore(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + var req replanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.EnergyWh != nil && (*req.EnergyWh <= 0 || *req.EnergyWh > 200000) { + httpx.WriteError(w, http.StatusBadRequest, "energy_wh must be within 0..200000") + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + if session.Status != StatusActive && session.Status != StatusPaused { + httpx.WriteError(w, http.StatusConflict, "replans need a live (active or paused) journey") + return + } + if session.DestLat == nil || session.DestLng == nil { + httpx.WriteError(w, http.StatusBadRequest, "journey needs destination coordinates to replan") + return + } + latest, err := h.trail.LatestCheckpoint(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: latest checkpoint failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + if latest == nil { + httpx.WriteError(w, http.StatusBadRequest, "check in first — a replan starts from the latest fix") + return + } + plans, err := h.store.ListPlans(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: list plans failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read plans") + return + } + cands, energyWh, ok := latestScoredPlan(plans) + if !ok { + httpx.WriteError(w, http.StatusBadRequest, "score stops first — a replan re-ranks the saved candidates") + return + } + if req.EnergyWh != nil { + energyWh = *req.EnergyWh + } + now := h.now().UTC() + sigs := make([]Signals, len(cands)) + for i := range cands { + cands[i].ArriveS = now.Unix() + sig, err := gatherSiteSignals(ctx, h.signals, h.waits, cands[i].Site, now) + if err != nil { + log.Error().Err(err).Str("site", cands[i].Site).Msg("journey: signals failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read stop signals") + return + } + sigs[i] = sig + } + stops := RankStops(latest.Lat, latest.Lng, *session.DestLat, *session.DestLng, energyWh, cands, sigs) + plan, err := json.Marshal(map[string]any{ + "kind": "replan", "energy_wh": energyWh, "stops": stops, + "candidates": candidateEcho(cands), + "from": map[string]any{"lat": latest.Lat, "lng": latest.Lng}, + }) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: plan encode failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save plan") + return + } + pv, err := h.store.SavePlan(ctx, id, plan, "replan from latest fix") + if err != nil { + if errors.Is(err, ErrNoSession) { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + log.Error().Err(err).Int64("id", id).Msg("journey: save plan failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save plan") + return + } + winner := "" + if len(stops) > 0 { + winner = stops[0].Site + } + httpx.WriteJSON(w, http.StatusOK, scoreResponse{ + SessionID: id, EnergyWh: energyWh, Stops: stops, + Winner: winner, PlanVersion: pv.Version, + }) +} + +// candidateEcho persists the rescorable inputs (site + coords only; +// arrivals are always "now" at replan time). +func candidateEcho(cands []Candidate) []map[string]any { + out := make([]map[string]any, 0, len(cands)) + for _, c := range cands { + out = append(out, map[string]any{"site": c.Site, "lat": c.Lat, "lng": c.Lng}) + } + return out +} diff --git a/internal/api/journey/replan_test.go b/internal/api/journey/replan_test.go new file mode 100644 index 000000000..e92a1e197 --- /dev/null +++ b/internal/api/journey/replan_test.go @@ -0,0 +1,306 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/waitoracle" +) + +func TestAssessDeviation(t *testing.T) { + oLat, oLng := 39.7392, -104.9903 + dLat, dLng := 39.0997, -94.5786 + // Points anchored at the origin: the lat/lng midpoint is NOT on + // the great-circle corridor (sagitta over ~900 km exceeds the + // drift threshold), so on-track must be measured at the origin. + at := &Checkpoint{Lat: oLat, Lng: oLng} + if d := AssessDeviation(&oLat, &oLng, &dLat, &dLng, at); d.Verdict != DeviationOnTrack { + t.Fatalf("origin = %+v, want on_track", d) + } + // ~5.5 km north of the corridor: drifted. + drift := &Checkpoint{Lat: oLat + 0.05, Lng: oLng} + d := AssessDeviation(&oLat, &oLng, &dLat, &dLng, drift) + if d.Verdict != DeviationDrifted || d.DeviationM == nil { + t.Fatalf("drift = %+v, want drifted", d) + } + if *d.DeviationM < 4000 || *d.DeviationM > 7000 { + t.Fatalf("drift_m = %f, want ~5.5km", *d.DeviationM) + } + // ~55 km north: off route. + off := &Checkpoint{Lat: oLat + 0.5, Lng: oLng} + if d := AssessDeviation(&oLat, &oLng, &dLat, &dLng, off); d.Verdict != DeviationOffRoute { + t.Fatalf("off = %+v, want off_route", d) + } + if d := AssessDeviation(&oLat, &oLng, &dLat, &dLng, nil); d.Verdict != DeviationUnknown || d.DeviationM != nil { + t.Fatalf("no fix = %+v, want unknown", d) + } + if d := AssessDeviation(nil, &oLng, &dLat, &dLng, at); d.Verdict != DeviationUnknown { + t.Fatalf("no coords = %+v, want unknown", d) + } +} + +func TestSavedCandidates(t *testing.T) { + good := `{"kind":"stop_scores","energy_wh":40000,"candidates":[ + {"site":"Kettleman","lat":38.0,"lng":-121.0}, + {"site":"Nowhere","lat":38.0,"lng":-118.0}]}` + cands, energy, ok := savedCandidates(json.RawMessage(good)) + if !ok || len(cands) != 2 || energy != 40000 || cands[0].Site != "Kettleman" { + t.Fatalf("good = %+v %f %v", cands, energy, ok) + } + replan := `{"kind":"replan","energy_wh":30000,"candidates":[{"site":"A","lat":1,"lng":1}]}` + if _, _, ok := savedCandidates(json.RawMessage(replan)); !ok { + t.Fatal("replan kind should rescore") + } + bad := []string{ + `{"kind":"departure","energy_wh":40000,"candidates":[{"site":"A","lat":1,"lng":1}]}`, + `{"kind":"stop_scores","energy_wh":0,"candidates":[{"site":"A","lat":1,"lng":1}]}`, + `{"kind":"stop_scores","energy_wh":40000,"candidates":[]}`, + `{"kind":"stop_scores","energy_wh":40000,"candidates":[{"site":"","lat":1,"lng":1}]}`, + `{"kind":"stop_scores","energy_wh":40000,"candidates":[{"site":"A","lat":91,"lng":1}]}`, + `{"kind":"stop_scores","energy_wh":40000}`, + `not json`, + ``, + } + for _, raw := range bad { + if c, e, ok := savedCandidates(json.RawMessage(raw)); ok { + t.Fatalf("raw %q: = %+v %f, want reject", raw, c, e) + } + } + many := `{"kind":"stop_scores","energy_wh":40000,"candidates":[` + for i := 0; i < 11; i++ { + if i > 0 { + many += "," + } + many += `{"site":"S","lat":1,"lng":1}` + } + many += `]}` + if _, _, ok := savedCandidates(json.RawMessage(many)); ok { + t.Fatal("11 candidates should reject") + } +} + +func TestLatestScoredPlanPicksMaxVersion(t *testing.T) { + // Insertion order (oldest first), as the fake store keeps it: the + // newest version must still win. + old := &PlanVersion{ID: 1, SessionID: 1, Version: 1, Plan: json.RawMessage( + `{"kind":"stop_scores","energy_wh":40000,"candidates":[{"site":"Old","lat":1,"lng":1}]}`)} + junk := &PlanVersion{ID: 2, SessionID: 1, Version: 2, Plan: json.RawMessage(`{"kind":"departure"}`)} + fresh := &PlanVersion{ID: 3, SessionID: 1, Version: 3, Plan: json.RawMessage( + `{"kind":"replan","energy_wh":30000,"candidates":[{"site":"New","lat":2,"lng":2}]}`)} + cands, energy, ok := latestScoredPlan([]*PlanVersion{old, junk, fresh}) + if !ok || len(cands) != 1 || cands[0].Site != "New" || energy != 30000 { + t.Fatalf("latest = %+v %f %v, want New/30000", cands, energy, ok) + } + if _, _, ok := latestScoredPlan([]*PlanVersion{junk}); ok { + t.Fatal("junk-only should reject") + } + if _, _, ok := latestScoredPlan(nil); ok { + t.Fatal("nil should reject") + } +} + +func TestParseNextStopReplan(t *testing.T) { + raw := json.RawMessage(`{"kind":"replan","stops":[{"site":"Flagler SC","wait_s":120}]}`) + next := ParseNextStop(raw) + if next == nil || next.Site != "Flagler SC" || next.WaitS == nil || *next.WaitS != 120 { + t.Fatalf("next = %+v", next) + } +} + +func replanSetup() (*fakeStore, *fakeTrail, *fakeWaits) { + f := newFakeStore() + f.sessions[1] = liveSession() + f.prices["Kettleman"] = 0.30 + f.peaks["Kettleman"] = []float64{150, 151, 149, 150, 152} + tr := &fakeTrail{points: []*Checkpoint{ + {ID: 1, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC), Lat: 39.4, Lng: -99.5}, + }} + w := &fakeWaits{histories: map[string]waitoracle.SiteHistory{"Kettleman": scoredHistory("Kettleman")}} + return f, tr, w +} + +func scoredPlan(t *testing.T, f *fakeStore, kind string, energy float64) { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "kind": kind, "energy_wh": energy, + "stops": []map[string]any{{"site": "Kettleman", "wait_s": 300}}, + "candidates": []map[string]any{ + {"site": "Kettleman", "lat": 38.0, "lng": -121.0}, + {"site": "Nowhere", "lat": 38.0, "lng": -118.0}, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := f.SavePlan(context.Background(), 1, raw, "v1"); err != nil { + t.Fatal(err) + } +} + +func TestAssess(t *testing.T) { + f, tr, w := replanSetup() + h := NewReplanHandler(f, tr, f, w) + rec := httptest.NewRecorder() + h.Assess(rec, liveRequest(http.MethodGet, "/journey/sessions/1/replan", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Assessment + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SessionID != 1 || got.Deviation == nil || got.Deviation.DeviationM == nil { + t.Fatalf("assessment = %+v", got) + } + if len(got.Evidence) != 2 { + t.Fatalf("evidence = %v, want 2 lines", got.Evidence) + } + if got.Latest == nil || got.Latest.ID != 1 { + t.Fatalf("latest = %+v", got.Latest) + } +} + +func TestAssessDegraded(t *testing.T) { + f, _, w := replanSetup() + h := NewReplanHandler(f, &fakeTrail{}, f, w) + rec := httptest.NewRecorder() + h.Assess(rec, liveRequest(http.MethodGet, "/journey/sessions/1/replan", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Assessment + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Deviation.Verdict != DeviationUnknown || got.Latest != nil { + t.Fatalf("degraded = %+v", got) + } + if len(got.Evidence) != 1 { + t.Fatalf("evidence = %v, want 1 line", got.Evidence) + } +} + +func TestAssessNotFound(t *testing.T) { + f, tr, w := replanSetup() + h := NewReplanHandler(f, tr, f, w) + rec := httptest.NewRecorder() + h.Assess(rec, liveRequest(http.MethodGet, "/journey/sessions/9/replan", "9", "")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} + +func TestRescore(t *testing.T) { + f, tr, w := replanSetup() + scoredPlan(t, f, "stop_scores", 40000) + h := NewReplanHandler(f, tr, f, w) + h.now = func() time.Time { return time.Date(2026, 9, 14, 10, 5, 0, 0, time.UTC) } + rec := httptest.NewRecorder() + h.Rescore(rec, liveRequest(http.MethodPost, "/journey/sessions/1/replan", "1", `{}`)) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got scoreResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Winner != "Kettleman" || len(got.Stops) != 2 || got.EnergyWh != 40000 { + t.Fatalf("rescore = %+v", got) + } + if got.PlanVersion != 2 { + t.Fatalf("plan_version = %d, want 2", got.PlanVersion) + } + plans, err := f.ListPlans(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if len(plans) != 2 { + t.Fatalf("plans = %d, want 2", len(plans)) + } + var saved struct { + Kind string `json:"kind"` + EnergyWh float64 `json:"energy_wh"` + Candidates []struct { + Site string `json:"site"` + } `json:"candidates"` + From struct { + Lat float64 `json:"lat"` + } `json:"from"` + } + if err := json.Unmarshal(plans[1].Plan, &saved); err != nil { + t.Fatal(err) + } + if saved.Kind != "replan" || len(saved.Candidates) != 2 || saved.From.Lat != 39.4 { + t.Fatalf("saved = %+v", saved) + } + // The replan stays rescorable: chain a second rescore off it. + rec2 := httptest.NewRecorder() + h.Rescore(rec2, liveRequest(http.MethodPost, "/journey/sessions/1/replan", "1", `{"energy_wh":30000}`)) + if rec2.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec2.Code, rec2.Body.String()) + } + var got2 scoreResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &got2); err != nil { + t.Fatal(err) + } + if got2.EnergyWh != 30000 || got2.PlanVersion != 3 { + t.Fatalf("override = %+v, want 30000/v3", got2) + } +} + +func TestRescoreErrors(t *testing.T) { + setup := func() (*fakeStore, *fakeTrail, *fakeWaits) { return replanSetup() } + cases := []struct { + name string + id string + body string + want int + seed bool + mut func(*testing.T, *fakeStore, *fakeTrail) + }{ + {"bad id", "abc", `{}`, http.StatusBadRequest, true, nil}, + {"bad body", "1", `{oops`, http.StatusBadRequest, true, nil}, + {"bad energy", "1", `{"energy_wh":-5}`, http.StatusBadRequest, true, nil}, + {"missing", "9", `{}`, http.StatusNotFound, true, nil}, + {"planned rejects", "1", `{}`, http.StatusConflict, true, func(_ *testing.T, f *fakeStore, _ *fakeTrail) { + s := liveSession() + s.Status = StatusPlanned + f.sessions[1] = s + }}, + {"no dest coords", "1", `{}`, http.StatusBadRequest, true, func(_ *testing.T, f *fakeStore, _ *fakeTrail) { + s := liveSession() + s.DestLat, s.DestLng = nil, nil + f.sessions[1] = s + }}, + {"no fix", "1", `{}`, http.StatusBadRequest, false, func(t *testing.T, f *fakeStore, tr *fakeTrail) { + t.Helper() + tr.points = nil + scoredPlan(t, f, "stop_scores", 40000) + }}, + {"no scored plan", "1", `{}`, http.StatusBadRequest, false, nil}, + {"waits down", "1", `{}`, http.StatusInternalServerError, true, nil}, + } + for _, c := range cases { + f, tr, w := setup() + if c.seed { + scoredPlan(t, f, "stop_scores", 40000) + } + if c.mut != nil { + c.mut(t, f, tr) + } + if c.name == "waits down" { + w.err = errors.New("db down") + } + h := NewReplanHandler(f, tr, f, w) + rec := httptest.NewRecorder() + h.Rescore(rec, liveRequest(http.MethodPost, "/journey/sessions/1/replan", c.id, c.body)) + if rec.Code != c.want { + t.Errorf("%s: code = %d, want %d (%s)", c.name, rec.Code, c.want, rec.Body.String()) + } + } +} diff --git a/internal/api/journey/report.go b/internal/api/journey/report.go new file mode 100644 index 000000000..75e8a51a0 --- /dev/null +++ b/internal/api/journey/report.go @@ -0,0 +1,267 @@ +package journey + +import ( + "encoding/json" + "math" + "net/http" + "sort" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// ChecklistRecap summarizes the latest readiness run. +type ChecklistRecap struct { + Ready int `json:"ready"` + Total int `json:"total"` +} + +// Report is the debrief card: what the trip covered, how long it +// took, how far it strayed from the straight line, and how ready it +// was. Computed live from the trail — for an unfinished trip it reads +// as a "so far" card, flagged in evidence. +type Report struct { + SessionID int64 `json:"session_id"` + Status string `json:"status"` + StartedAt *time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at"` + DurationS *float64 `json:"duration_s"` + DistanceM *float64 `json:"distance_m"` + Fixes int `json:"fixes"` + Plans int `json:"plans"` + Replans int `json:"replans"` + Detour *float64 `json:"detour"` + RouteFactor *float64 `json:"route_factor"` + RouteTrips int `json:"route_trips"` + Checklist *ChecklistRecap `json:"checklist"` + Evidence []string `json:"evidence"` +} + +// TrailDistanceM measures the driven path. Odometer span wins when +// the time-first and time-last fixes both carry one (road truth); +// otherwise the haversine sum over time-ordered fixes. Fewer than two +// fixes measures nothing. Order-agnostic. Pure. +func TrailDistanceM(points []*Checkpoint) (float64, bool) { + if len(points) < 2 { + return 0, false + } + ordered := make([]*Checkpoint, len(points)) + copy(ordered, points) + sort.SliceStable(ordered, func(i, j int) bool { + return ordered[i].RecordedAt.Before(ordered[j].RecordedAt) + }) + first, last := ordered[0], ordered[len(ordered)-1] + if first.OdometerM != nil && last.OdometerM != nil && *last.OdometerM >= *first.OdometerM { + return *last.OdometerM - *first.OdometerM, true + } + sum := 0.0 + for i := 1; i < len(ordered); i++ { + sum += haversineM(ordered[i-1].Lat, ordered[i-1].Lng, ordered[i].Lat, ordered[i].Lng) + } + return sum, true +} + +// TripDurationS measures started→ended, or started→last-fix for a trip +// still live. Nil without a start. Negative spans (clock skew) read as +// no duration. Pure. +func TripDurationS(started, ended *time.Time, fixes []*Checkpoint) (float64, bool) { + if started == nil { + return 0, false + } + end := ended + if end == nil { + var last *time.Time + for _, f := range fixes { + if f == nil { + continue + } + if last == nil || f.RecordedAt.After(*last) { + t := f.RecordedAt + last = &t + } + } + end = last + } + if end == nil { + return 0, false + } + d := end.Sub(*started).Seconds() + if d < 0 { + return 0, false + } + return d, true +} + +// DetourRatio compares the driven path to the straight-line plan: +// 1.0 hugs the line, 1.5 drove half as far again. Below 1.0 the trip +// covered less than the full leg (or cut the corner). Nil without a +// positive straight leg. Rounded to 2 dp for stable display. Pure. +func DetourRatio(trailM, straightM float64) *float64 { + if straightM <= 0 { + return nil + } + r := math.Round(trailM/straightM*100) / 100 + return &r +} + +// CountPlans tallies versions and replans (kind "replan"). Pure. +func CountPlans(plans []*PlanVersion) (total, replans int) { + for _, p := range plans { + if p == nil { + continue + } + total++ + var kind struct { + Kind string `json:"kind"` + } + if err := json.Unmarshal(p.Plan, &kind); err == nil && kind.Kind == "replan" { + replans++ + } + } + return total, replans +} + +// RecapChecklist counts ready (ok) items in the latest run. Nil run +// yields nil. Pure. +func RecapChecklist(run *Run) *ChecklistRecap { + if run == nil { + return nil + } + recap := &ChecklistRecap{Total: len(run.Items)} + for _, item := range run.Items { + if item.Status == ItemOK { + recap.Ready++ + } + } + return recap +} + +// ReportHandler serves the debrief card. Stateless beyond constructor +// inputs; safe for concurrent use. +type ReportHandler struct { + store SessionStore + trail TrailStore + runs RunStore + now func() time.Time +} + +// NewReportHandler wires the handler. Panics on nil inputs (fail-fast +// wiring contract, matching sibling handlers). +func NewReportHandler(store SessionStore, trail TrailStore, runs RunStore) *ReportHandler { + if store == nil || trail == nil || runs == nil { + panic("journey: nil dependency") + } + return &ReportHandler{store: store, trail: trail, runs: runs, now: time.Now} +} + +// Card serves GET /journey/sessions/{id}/report: the debrief card. +func (h *ReportHandler) Card(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + trail, err := h.trail.Trail(ctx, id, 100) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: trail failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read trail") + return + } + plans, err := h.store.ListPlans(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: list plans failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read plans") + return + } + run, err := h.runs.LatestChecklistRun(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: latest checklist failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read checklist") + return + } + out := Report{ + SessionID: id, Status: session.Status, + StartedAt: session.StartedAt, EndedAt: session.EndedAt, + Fixes: len(trail), Checklist: RecapChecklist(run), + } + if d, ok := TrailDistanceM(trail); ok { + out.DistanceM = &d + } + if d, ok := TripDurationS(session.StartedAt, session.EndedAt, trail); ok { + out.DurationS = &d + } + out.Plans, out.Replans = CountPlans(plans) + if out.DistanceM != nil { + straight := straightM(session) + out.Detour = DetourRatio(*out.DistanceM, straight) + } + factor, trips, err := routeFactorFor(ctx, h.trail, session) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: route history failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read route history") + return + } + out.RouteFactor, out.RouteTrips = factor, trips + out.Evidence = reportEvidence(session, &out, straightM(session)) + httpx.WriteJSON(w, http.StatusOK, out) +} + +func straightM(session *Session) float64 { + if session.OriginLat == nil || session.OriginLng == nil || session.DestLat == nil || session.DestLng == nil { + return 0 + } + return haversineM(*session.OriginLat, *session.OriginLng, *session.DestLat, *session.DestLng) +} + +func reportEvidence(session *Session, rep *Report, straight float64) []string { + out := []string{} + if rep.DurationS != nil { + out = append(out, "trip time "+formatDur(*rep.DurationS)) + } else { + out = append(out, "trip never started") + } + if rep.DistanceM != nil { + out = append(out, formatKm("drove ", *rep.DistanceM/1000)) + } else { + out = append(out, "no trail — nothing driven yet") + } + if rep.Detour != nil { + out = append(out, strconv.FormatFloat(*rep.Detour, 'f', 2, 64)+"× the straight line") + } else if straight <= 0 { + out = append(out, "route coordinates missing — detour unavailable") + } + if rep.RouteFactor != nil { + out = append(out, "usually "+strconv.FormatFloat(*rep.RouteFactor, 'f', 2, 64)+"× on this route ("+strconv.Itoa(rep.RouteTrips)+" trips)") + } + switch rep.Replans { + case 0: + out = append(out, "no replans — the first plan held") + case 1: + out = append(out, "1 replan en route") + default: + out = append(out, strconv.Itoa(rep.Replans)+" replans en route") + } + if rep.Checklist != nil { + out = append(out, strconv.Itoa(rep.Checklist.Ready)+"/"+strconv.Itoa(rep.Checklist.Total)+" ready at the last check") + } else { + out = append(out, "no checklist run") + } + if session.Status == StatusActive || session.Status == StatusPaused { + out = append(out, "trip still live — card reads so far") + } + return out +} diff --git a/internal/api/journey/report_test.go b/internal/api/journey/report_test.go new file mode 100644 index 000000000..80f0f66ab --- /dev/null +++ b/internal/api/journey/report_test.go @@ -0,0 +1,230 @@ +package journey + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestTrailDistanceM(t *testing.T) { + base := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) + a := &Checkpoint{RecordedAt: base, Lat: 39.7, Lng: -105.0, OdometerM: fptr(100000)} + b := &Checkpoint{RecordedAt: base.Add(time.Hour), Lat: 39.5, Lng: -104.0, OdometerM: fptr(190000)} + // Odometer span wins regardless of input order. + if d, ok := TrailDistanceM([]*Checkpoint{b, a}); !ok || d != 90000 { + t.Fatalf("odometer = %f %v, want 90000", d, ok) + } + c := &Checkpoint{RecordedAt: base, Lat: 39.7, Lng: -105.0} + d := &Checkpoint{RecordedAt: base.Add(100 * time.Second), Lat: 39.71, Lng: -105.0} + if dist, ok := TrailDistanceM([]*Checkpoint{c, d}); !ok || dist < 1110 || dist > 1114 { + t.Fatalf("haversine = %f %v, want ~1112", dist, ok) + } + if _, ok := TrailDistanceM([]*Checkpoint{c}); ok { + t.Fatal("single fix should measure nothing") + } + if _, ok := TrailDistanceM(nil); ok { + t.Fatal("nil should measure nothing") + } + // Odometer rollback falls back to coordinates, not negative road. + e := &Checkpoint{RecordedAt: base, Lat: 39.7, Lng: -105.0, OdometerM: fptr(190000)} + f := &Checkpoint{RecordedAt: base.Add(100 * time.Second), Lat: 39.71, Lng: -105.0, OdometerM: fptr(100000)} + if dist, ok := TrailDistanceM([]*Checkpoint{e, f}); !ok || dist < 1110 || dist > 1114 { + t.Fatalf("rollback = %f %v, want ~1112", dist, ok) + } +} + +func TestTripDurationS(t *testing.T) { + start := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 9, 14, 11, 30, 0, 0, time.UTC) + if d, ok := TripDurationS(&start, &end, nil); !ok || d != 9000 { + t.Fatalf("closed = %f %v, want 9000", d, ok) + } + fixes := []*Checkpoint{{RecordedAt: start.Add(time.Hour)}} + if d, ok := TripDurationS(&start, nil, fixes); !ok || d != 3600 { + t.Fatalf("live = %f %v, want 3600", d, ok) + } + if _, ok := TripDurationS(nil, &end, fixes); ok { + t.Fatal("no start should measure nothing") + } + if _, ok := TripDurationS(&start, nil, nil); ok { + t.Fatal("no end or fix should measure nothing") + } + skew := start.Add(-time.Minute) + if _, ok := TripDurationS(&start, &skew, nil); ok { + t.Fatal("negative span should measure nothing") + } +} + +func TestDetourRatio(t *testing.T) { + if r := DetourRatio(90000, 80000); r == nil || *r < 1.12 || *r > 1.13 { + t.Fatalf("ratio = %v, want 1.13", r) + } + if r := DetourRatio(0, 80000); r == nil || *r != 0 { + t.Fatalf("zero trail = %v, want 0", r) + } + if r := DetourRatio(90000, 0); r != nil { + t.Fatalf("zero straight = %v, want nil", r) + } +} + +func TestCountPlans(t *testing.T) { + plans := []*PlanVersion{ + {Version: 1, Plan: json.RawMessage(`{"kind":"stop_scores"}`)}, + {Version: 2, Plan: json.RawMessage(`{"kind":"replan"}`)}, + {Version: 3, Plan: json.RawMessage(`{"kind":"replan"}`)}, + {Version: 4, Plan: json.RawMessage(`not json`)}, + nil, + } + if total, replans := CountPlans(plans); total != 4 || replans != 2 { + t.Fatalf("counts = %d/%d, want 4/2", total, replans) + } +} + +func TestRecapChecklist(t *testing.T) { + if r := RecapChecklist(nil); r != nil { + t.Fatalf("nil = %+v, want nil", r) + } + run := &Run{Items: []Item{ + {Key: "a", Status: ItemOK}, {Key: "b", Status: ItemAttention}, {Key: "c", Status: ItemOK}, + }} + if r := RecapChecklist(run); r.Ready != 2 || r.Total != 3 { + t.Fatalf("recap = %+v, want 2/3", r) + } +} + +func reportSession() *Session { + s := liveSession() + s.Status = StatusCompleted + start := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 9, 14, 11, 30, 0, 0, time.UTC) + s.StartedAt, s.EndedAt = &start, &end + return s +} + +func TestCard(t *testing.T) { + f := newFakeStore() + f.sessions[1] = reportSession() + tr := &fakeTrail{points: []*Checkpoint{ + {ID: 1, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC), + Lat: 39.7392, Lng: -104.9903, OdometerM: fptr(100000)}, + {ID: 2, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 11, 30, 0, 0, time.UTC), + Lat: 39.0997, Lng: -94.5786, OdometerM: fptr(1000000)}, + }} + f.plans[1] = []*PlanVersion{ + {ID: 1, SessionID: 1, Version: 1, Plan: json.RawMessage(`{"kind":"stop_scores"}`)}, + {ID: 2, SessionID: 1, Version: 2, Plan: json.RawMessage(`{"kind":"replan"}`)}, + } + runs := &fakeRuns{runs: map[int64][]*Run{1: {{ + ID: 1, SessionID: 1, RunAt: time.Date(2026, 9, 14, 8, 50, 0, 0, time.UTC), + Items: []Item{{Key: "a", Status: ItemOK}, {Key: "b", Status: ItemOK}, {Key: "c", Status: ItemAction}}, + }}}} + h := NewReportHandler(f, tr, runs) + rec := httptest.NewRecorder() + h.Card(rec, liveRequest(http.MethodGet, "/journey/sessions/1/report", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Report + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.SessionID != 1 || got.Status != StatusCompleted { + t.Fatalf("report = %+v", got) + } + if got.DurationS == nil || *got.DurationS != 9000 { + t.Fatalf("duration = %v, want 9000", got.DurationS) + } + if got.DistanceM == nil || *got.DistanceM != 900000 { + t.Fatalf("distance = %v, want 900000", got.DistanceM) + } + if got.Fixes != 2 || got.Plans != 2 || got.Replans != 1 { + t.Fatalf("counts = %d/%d/%d, want 2/2/1", got.Fixes, got.Plans, got.Replans) + } + if got.Detour == nil || *got.Detour < 0.9 || *got.Detour > 1.2 { + t.Fatalf("detour = %v, want ~1.0", got.Detour) + } + if got.Checklist == nil || got.Checklist.Ready != 2 || got.Checklist.Total != 3 { + t.Fatalf("checklist = %+v, want 2/3", got.Checklist) + } + if len(got.Evidence) != 5 { + t.Fatalf("evidence = %v, want 5 lines", got.Evidence) + } +} + +func TestCardRouteFactor(t *testing.T) { + f := newFakeStore() + s := reportSession() + s.OriginName, s.DestName = "Denver", "KC" + f.sessions[1] = s + tr := &fakeTrail{points: []*Checkpoint{ + {ID: 1, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC), + Lat: 39.7392, Lng: -104.9903, OdometerM: fptr(100000)}, + {ID: 2, SessionID: 1, RecordedAt: time.Date(2026, 9, 14, 11, 30, 0, 0, time.UTC), + Lat: 39.0997, Lng: -94.5786, OdometerM: fptr(1000000)}, + }, legs: []RouteLeg{ + {DistanceM: 990000, StraightM: 900000}, + {DistanceM: 900000, StraightM: 900000}, + {DistanceM: 945000, StraightM: 900000}, + }} + h := NewReportHandler(f, tr, &fakeRuns{runs: map[int64][]*Run{}}) + rec := httptest.NewRecorder() + h.Card(rec, liveRequest(http.MethodGet, "/journey/sessions/1/report", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Report + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.RouteFactor == nil || *got.RouteFactor < 1.049 || *got.RouteFactor > 1.051 { + t.Fatalf("factor = %v, want 1.05", got.RouteFactor) + } + if got.RouteTrips != 3 { + t.Fatalf("trips = %d, want 3", got.RouteTrips) + } + // duration, distance, detour, route, replans, checklist. + if len(got.Evidence) != 6 { + t.Fatalf("evidence = %v, want 6 lines", got.Evidence) + } +} + +func TestCardLive(t *testing.T) { + f := newFakeStore() + s := liveSession() + start := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC) + s.StartedAt = &start + f.sessions[1] = s + tr := &fakeTrail{points: []*Checkpoint{ + {ID: 1, SessionID: 1, RecordedAt: start, Lat: 39.7392, Lng: -104.9903}, + }} + h := NewReportHandler(f, tr, &fakeRuns{runs: map[int64][]*Run{}}) + rec := httptest.NewRecorder() + h.Card(rec, liveRequest(http.MethodGet, "/journey/sessions/1/report", "1", "")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got Report + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.DurationS == nil || *got.DurationS != 0 { + t.Fatalf("duration = %v, want 0", got.DurationS) + } + if got.DistanceM != nil || got.Detour != nil || got.Checklist != nil { + t.Fatalf("live card should omit distance/detour/checklist: %+v", got) + } + if len(got.Evidence) != 5 { + t.Fatalf("evidence = %v, want 5 lines", got.Evidence) + } +} + +func TestCardNotFound(t *testing.T) { + h := NewReportHandler(newFakeStore(), &fakeTrail{}, &fakeRuns{runs: map[int64][]*Run{}}) + rec := httptest.NewRecorder() + h.Card(rec, liveRequest(http.MethodGet, "/journey/sessions/9/report", "9", "")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} diff --git a/internal/api/journey/runs.go b/internal/api/journey/runs.go new file mode 100644 index 000000000..940484697 --- /dev/null +++ b/internal/api/journey/runs.go @@ -0,0 +1,63 @@ +package journey + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// SaveChecklistRun persists one evaluation. Items marshal to JSONB; a +// missing session reports ErrNoSession. +func (s *Store) SaveChecklistRun(ctx context.Context, sessionID int64, items []Item) (*Run, error) { + raw, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("journey: encode checklist: %w", err) + } + var exists bool + if err := s.db.Pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM journey_sessions WHERE id = $1)`, sessionID).Scan(&exists); err != nil { + return nil, fmt.Errorf("journey: checklist session check: %w", err) + } + if !exists { + return nil, ErrNoSession + } + run := &Run{} + var stored json.RawMessage + if err := s.db.Pool.QueryRow(ctx, ` + INSERT INTO journey_checklist_runs (session_id, items) + VALUES ($1, $2) + RETURNING id, session_id, run_at, items`, sessionID, string(raw), + ).Scan(&run.ID, &run.SessionID, &run.RunAt, &stored); err != nil { + return nil, fmt.Errorf("journey: save checklist: %w", err) + } + if err := json.Unmarshal(stored, &run.Items); err != nil { + return nil, fmt.Errorf("journey: decode checklist: %w", err) + } + return run, nil +} + +// LatestChecklistRun returns the newest run for a session, or nil when +// the checklist never ran. +func (s *Store) LatestChecklistRun(ctx context.Context, sessionID int64) (*Run, error) { + run := &Run{} + var stored json.RawMessage + err := s.db.Pool.QueryRow(ctx, ` + SELECT id, session_id, run_at, items FROM journey_checklist_runs + WHERE session_id = $1 ORDER BY run_at DESC LIMIT 1`, sessionID, + ).Scan(&run.ID, &run.SessionID, &run.RunAt, &stored) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("journey: latest checklist: %w", err) + } + if err := json.Unmarshal(stored, &run.Items); err != nil { + return nil, fmt.Errorf("journey: decode checklist: %w", err) + } + return run, nil +} + +// Compile-time port assertion. +var _ RunStore = (*Store)(nil) diff --git a/internal/api/journey/score.go b/internal/api/journey/score.go new file mode 100644 index 000000000..077aa905b --- /dev/null +++ b/internal/api/journey/score.go @@ -0,0 +1,248 @@ +package journey + +import ( + "math" + "sort" + "strconv" +) + +// Signal weights for stop ranking. They sum to 1; when a signal is +// unavailable for every candidate it drops out and the remainder +// renormalize, so a missing feed degrades the ranking instead of +// zeroing it. +const ( + weightWait = 0.35 + weightPrice = 0.30 + weightHealth = 0.20 + weightCorridor = 0.15 +) + +// Candidate is one caller-nominated stop: a fleet-known site plus where +// it sits and when the driver would arrive. +type Candidate struct { + Site string + Lat float64 + Lng float64 + ArriveS int64 // arrival instant, unix seconds (any zone; UTC-normalised downstream) +} + +// Signals are the measured inputs per candidate. Nil means unavailable: +// the signal drops out of that candidate's blend. +type Signals struct { + WaitS *float64 // expected queue wait, SI seconds + PerKWh *float64 // realized price per kWh + PeakKW []float64 // peak-kW samples for the health read + Available bool // any signal present at all +} + +// ScoredStop is one ranked candidate with its per-signal breakdown. +type ScoredStop struct { + Site string `json:"site"` + Score float64 `json:"score"` + WaitS *float64 `json:"wait_s"` + UnitPrice *float64 `json:"unit_price"` + Health *float64 `json:"health"` + CorridorM float64 `json:"corridor_m"` + Evidence []string `json:"evidence"` +} + +// RankStops scores candidates for one charge need. origin/dest anchor +// the straight-line corridor; energyWh scales the price signal into +// absolute spend. Pure: no I/O, deterministic. SI throughout. +func RankStops(originLat, originLng, destLat, destLng, energyWh float64, cands []Candidate, sigs []Signals) []ScoredStop { + out := make([]ScoredStop, 0, len(cands)) + for i, c := range cands { + var sig Signals + if i < len(sigs) { + sig = sigs[i] + } + health := healthScore(sig.PeakKW) + out = append(out, ScoredStop{ + Site: c.Site, + WaitS: sig.WaitS, + UnitPrice: scaledPrice(sig.PerKWh, energyWh), + Health: health, + CorridorM: corridorDeviationM(originLat, originLng, destLat, destLng, c.Lat, c.Lng), + Evidence: evidenceFor(sig, energyWh), + }) + } + // Relative 0..100 per signal across candidates; a lone candidate + // with data scores 100, without data the signal is skipped. + norm := func(pick func(*ScoredStop) *float64, lowerBetter bool) map[int]float64 { + vals := map[int]float64{} + for i := range out { + if v := pick(&out[i]); v != nil { + vals[i] = *v + } + } + scores := map[int]float64{} + if len(vals) == 0 { + return scores + } + if len(vals) == 1 { + for i := range vals { + scores[i] = 100 + } + return scores + } + lo, hi := math.Inf(1), math.Inf(-1) + for _, v := range vals { + lo, hi = math.Min(lo, v), math.Max(hi, v) + } + if hi == lo { + for i := range vals { + scores[i] = 100 + } + return scores + } + for i, v := range vals { + if lowerBetter { + scores[i] = (hi - v) / (hi - lo) * 100 + } else { + scores[i] = (v - lo) / (hi - lo) * 100 + } + } + return scores + } + waitN := norm(func(s *ScoredStop) *float64 { return s.WaitS }, true) + priceN := norm(func(s *ScoredStop) *float64 { return s.UnitPrice }, true) + healthN := norm(func(s *ScoredStop) *float64 { return s.Health }, false) + corrVals := map[int]float64{} + for i := range out { + corrVals[i] = out[i].CorridorM + } + corrN := map[int]float64{} + { + lo, hi := math.Inf(1), math.Inf(-1) + for _, v := range corrVals { + lo, hi = math.Min(lo, v), math.Max(hi, v) + } + for i, v := range corrVals { + if hi == lo { + corrN[i] = 100 + } else { + corrN[i] = (hi - v) / (hi - lo) * 100 + } + } + } + for i := range out { + sum, wsum := 0.0, 0.0 + if v, ok := waitN[i]; ok { + sum, wsum = sum+v*weightWait, wsum+weightWait + } + if v, ok := priceN[i]; ok { + sum, wsum = sum+v*weightPrice, wsum+weightPrice + } + if v, ok := healthN[i]; ok { + sum, wsum = sum+v*weightHealth, wsum+weightHealth + } + sum, wsum = sum+corrN[i]*weightCorridor, wsum+weightCorridor + out[i].Score = round1(sum / wsum) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) + return out +} + +// scaledPrice converts $/kWh into absolute spend for the charge need so +// the price signal reflects money, not rates. Nil in, nil out. +func scaledPrice(perKWh *float64, energyWh float64) *float64 { + if perKWh == nil || energyWh <= 0 { + return perKWh + } + v := *perKWh * energyWh / 1000 + return &v +} + +// healthScore reads stall consistency from peak-kW samples: a site +// where every session peaks alike is healthy; wild variance means +// degraded stalls. Needs 3+ samples; 0..100, higher is healthier. +func healthScore(peaks []float64) *float64 { + vals := make([]float64, 0, len(peaks)) + for _, p := range peaks { + if p > 0 { + vals = append(vals, p) + } + } + if len(vals) < 3 { + return nil + } + mean := 0.0 + for _, v := range vals { + mean += v + } + mean /= float64(len(vals)) + if mean <= 0 { + return nil + } + variance := 0.0 + for _, v := range vals { + d := v - mean + variance += d * d + } + variance /= float64(len(vals)) + cv := math.Sqrt(variance) / mean + depth := math.Min(1, float64(len(vals))/20) // full credit at 20+ samples + h := math.Max(0, 1-cv) * (0.5 + 0.5*depth) * 100 + return &h +} + +// corridorDeviationM is the cross-track distance from the candidate to +// the straight origin→destination line: a documented proxy for detour +// until turn-by-turn routing exists. Degenerate (zero-length) routes +// measure from the origin point. +func corridorDeviationM(oLat, oLng, dLat, dLng, cLat, cLng float64) float64 { + const earthM = 6371000.0 + toRad := func(d float64) float64 { return d * math.Pi / 180 } + lat1, lng1 := toRad(oLat), toRad(oLng) + lat2, lng2 := toRad(dLat), toRad(dLng) + lat3, lng3 := toRad(cLat), toRad(cLng) + hav := func(a float64) float64 { + s := math.Sin(a / 2) + return s * s + } + central := func(la1, ln1, la2, ln2 float64) float64 { + h := hav(la2-la1) + math.Cos(la1)*math.Cos(la2)*hav(ln2-ln1) + return 2 * math.Asin(math.Min(1, math.Sqrt(math.Max(0, h)))) + } + d13 := central(lat1, lng1, lat3, lng3) + d12 := central(lat1, lng1, lat2, lng2) + if d12 == 0 { + return d13 * earthM + } + y := math.Sin(lng3-lng1) * math.Cos(lat3) + x := math.Cos(lat1)*math.Sin(lat3) - math.Sin(lat1)*math.Cos(lat3)*math.Cos(lng3-lng1) + bearing13 := math.Atan2(y, x) + y2 := math.Sin(lng2-lng1) * math.Cos(lat2) + x2 := math.Cos(lat1)*math.Sin(lat2) - math.Sin(lat1)*math.Cos(lat2)*math.Cos(lng2-lng1) + bearing12 := math.Atan2(y2, x2) + xt := math.Asin(math.Max(-1, math.Min(1, math.Sin(d13)*math.Sin(bearing13-bearing12)))) + return math.Abs(xt) * earthM +} + +func evidenceFor(sig Signals, energyWh float64) []string { + out := []string{} + if sig.WaitS != nil { + out = append(out, "expected wait "+formatDur(*sig.WaitS)) + } + if sig.PerKWh != nil && energyWh > 0 { + out = append(out, "≈$"+strconv.FormatFloat(*sig.PerKWh*energyWh/1000, 'f', 2, 64)+" for this charge") + } else if sig.PerKWh != nil { + out = append(out, "$"+strconv.FormatFloat(*sig.PerKWh, 'f', 2, 64)+"/kWh realized") + } + if len(sig.PeakKW) >= 3 { + out = append(out, strconv.Itoa(len(sig.PeakKW))+" peak-power samples") + } + if !sig.Available { + out = append(out, "no fleet data — ranked on corridor only") + } + return out +} + +func formatDur(s float64) string { + if s < 90 { + return strconv.Itoa(int(math.Round(s))) + "s" + } + return strconv.Itoa(int(math.Round(s/60))) + " min" +} + +func round1(v float64) float64 { return math.Round(v*10) / 10 } diff --git a/internal/api/journey/score_test.go b/internal/api/journey/score_test.go new file mode 100644 index 000000000..60263365c --- /dev/null +++ b/internal/api/journey/score_test.go @@ -0,0 +1,110 @@ +package journey + +import ( + "math" + "testing" +) + +func f64(v float64) *float64 { return &v } + +func TestRankStopsOrdersByBlend(t *testing.T) { + cands := []Candidate{ + {Site: "cheap-near", Lat: 38.0, Lng: -121.0}, + {Site: "pricey-far", Lat: 38.5, Lng: -119.0}, + } + sigs := []Signals{ + {WaitS: f64(0), PerKWh: f64(0.25), PeakKW: []float64{150, 150, 150, 150}, Available: true}, + {WaitS: f64(1800), PerKWh: f64(0.55), PeakKW: []float64{40, 150, 90, 150}, Available: true}, + } + got := RankStops(37.0, -122.0, 39.0, -120.0, 40000, cands, sigs) + if len(got) != 2 { + t.Fatalf("stops = %d, want 2", len(got)) + } + if got[0].Site != "cheap-near" || got[1].Site != "pricey-far" { + t.Fatalf("order = %q, %q", got[0].Site, got[1].Site) + } + if got[0].Score <= got[1].Score { + t.Fatalf("scores not ranked: %v vs %v", got[0].Score, got[1].Score) + } +} + +func TestRankStopsDegradesWithoutSignals(t *testing.T) { + cands := []Candidate{{Site: "ghost", Lat: 38.0, Lng: -121.0}} + got := RankStops(37.0, -122.0, 39.0, -120.0, 40000, cands, []Signals{{}}) + if len(got) != 1 { + t.Fatalf("stops = %d, want 1", len(got)) + } + if got[0].Score != 100 { // corridor-only still ranks + t.Fatalf("score = %v, want 100", got[0].Score) + } + if len(got[0].Evidence) == 0 { + t.Fatal("evidence is empty") + } +} + +func TestRankStopsDeterministic(t *testing.T) { + cands := []Candidate{ + {Site: "a", Lat: 38.0, Lng: -121.0}, + {Site: "b", Lat: 38.1, Lng: -121.1}, + } + sigs := []Signals{ + {WaitS: f64(300), PerKWh: f64(0.3), Available: true}, + {WaitS: f64(600), PerKWh: f64(0.4), Available: true}, + } + a := RankStops(37.0, -122.0, 39.0, -120.0, 40000, cands, sigs) + b := RankStops(37.0, -122.0, 39.0, -120.0, 40000, cands, sigs) + if len(a) != 2 || a[0].Site != b[0].Site || a[0].Score != b[0].Score || a[1].Score != b[1].Score { + t.Fatalf("nondeterministic:\n%+v\n%+v", a, b) + } +} + +func TestHealthScore(t *testing.T) { + if h := healthScore(nil); h != nil { + t.Fatalf("nil peaks = %v, want nil", *h) + } + if h := healthScore([]float64{150, 150}); h != nil { + t.Fatalf("2 peaks = %v, want nil", *h) + } + steady := healthScore([]float64{150, 150, 150, 150, 150, 150}) + wild := healthScore([]float64{20, 200, 30, 190, 25, 195}) + if steady == nil || wild == nil { + t.Fatal("expected non-nil health") + } + if *steady <= *wild { + t.Fatalf("steady %v should beat wild %v", *steady, *wild) + } + if *steady < 0 || *steady > 100 || *wild < 0 || *wild > 100 { + t.Fatalf("out of range: %v %v", *steady, *wild) + } +} + +func TestCorridorDeviationM(t *testing.T) { + // Midpoint of the line deviates ~0. + if d := corridorDeviationM(37.0, -122.0, 39.0, -120.0, 38.0, -121.0); d > 2000 { + t.Fatalf("on-line deviation = %v m, want near 0", d) + } + // Two degrees of longitude off at lat 38 ≈ 175 km. + if d := corridorDeviationM(37.0, -122.0, 39.0, -120.0, 38.0, -119.0); d < 100000 { + t.Fatalf("off-line deviation = %v m, want large", d) + } + // Degenerate route measures from the origin point. + d := corridorDeviationM(37.0, -122.0, 37.0, -122.0, 38.0, -122.0) + if math.Abs(d-111195) > 2000 { // one degree of latitude + t.Fatalf("degenerate = %v m, want ~111195", d) + } + if d := corridorDeviationM(37.0, -122.0, 37.0, -122.0, 37.0, -122.0); d != 0 { + t.Fatalf("same point = %v, want 0", d) + } +} + +func TestScaledPrice(t *testing.T) { + if got := scaledPrice(nil, 40000); got != nil { + t.Fatalf("nil in = %v, want nil", *got) + } + if got := scaledPrice(f64(0.3), 0); *got != 0.3 { + t.Fatalf("zero energy = %v, want passthrough", *got) + } + if got := scaledPrice(f64(0.3), 40000); *got != 12 { + t.Fatalf("scaled = %v, want 12", *got) + } +} diff --git a/internal/api/journey/signals.go b/internal/api/journey/signals.go new file mode 100644 index 000000000..7180fa3df --- /dev/null +++ b/internal/api/journey/signals.go @@ -0,0 +1,56 @@ +package journey + +import ( + "context" + "fmt" +) + +// peakSampleLimit bounds the peak-kW pull per site for the health read. +const peakSampleLimit = 500 + +// SitePeaks returns recent peak-kW samples for a site, newest first. +// Empty (not an error) when the site has no metered sessions. +func (s *Store) SitePeaks(ctx context.Context, site string) ([]float64, error) { + rows, err := s.db.Pool.Query(ctx, ` + SELECT peak_power_kw FROM tesla_charging_sessions + WHERE site_location_name = $1 AND peak_power_kw > 0 + ORDER BY charge_start_datetime DESC + LIMIT $2`, site, peakSampleLimit) + if err != nil { + return nil, fmt.Errorf("journey: site peaks: %w", err) + } + defer rows.Close() + out := []float64{} + for rows.Next() { + var kw float64 + if err := rows.Scan(&kw); err != nil { + return nil, fmt.Errorf("journey: scan peak: %w", err) + } + out = append(out, kw) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("journey: site peaks: %w", err) + } + return out, nil +} + +// SitePrice returns the realized price per kWh for a site from invoice +// history, plus the metered-entry sample count. ok=false when unpriced. +// Multi-currency fleets compare realized ratios per site; sites almost +// always bill one currency, and the ranking is relative regardless. +func (s *Store) SitePrice(ctx context.Context, site string) (perKWh float64, samples int, ok bool, err error) { + var wh, spend *float64 + var n int + err = s.db.Pool.QueryRow(ctx, ` + SELECT SUM(usage_wh), SUM(total_due), COUNT(*) + FROM tesla_charging_history + WHERE site_location_name = $1 AND usage_wh > 0 AND total_due > 0`, site, + ).Scan(&wh, &spend, &n) + if err != nil { + return 0, 0, false, fmt.Errorf("journey: site price: %w", err) + } + if wh == nil || spend == nil || *wh <= 0 || n == 0 { + return 0, 0, false, nil + } + return *spend / (*wh / 1000), n, true, nil +} diff --git a/internal/api/journey/trail.go b/internal/api/journey/trail.go new file mode 100644 index 000000000..141fef3b8 --- /dev/null +++ b/internal/api/journey/trail.go @@ -0,0 +1,197 @@ +package journey + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Checkpoint is one trail snapshot. +type Checkpoint struct { + ID int64 `json:"id"` + SessionID int64 `json:"session_id"` + RecordedAt time.Time `json:"recorded_at"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + SocPct *float64 `json:"soc_pct"` + OdometerM *float64 `json:"odometer_m"` +} + +// NewCheckpoint carries the append fields. RecordedAt defaults to now +// when zero. +type NewCheckpoint struct { + RecordedAt time.Time + Lat float64 + Lng float64 + SocPct *float64 + OdometerM *float64 +} + +// AppendCheckpoint inserts one trail point. Retried posts with the +// same recorded_at return the existing row (idempotent companion +// retry), matched on the unique key rather than error strings. +func (s *Store) AppendCheckpoint(ctx context.Context, sessionID int64, in NewCheckpoint) (*Checkpoint, error) { + if in.RecordedAt.IsZero() { + in.RecordedAt = time.Now().UTC() + } + cp := &Checkpoint{} + err := s.db.Pool.QueryRow(ctx, ` + INSERT INTO journey_checkpoints (session_id, recorded_at, lat, lng, soc_pct, odometer_m) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (session_id, recorded_at) DO NOTHING + RETURNING id, session_id, recorded_at, lat, lng, soc_pct, odometer_m`, + sessionID, in.RecordedAt, in.Lat, in.Lng, in.SocPct, in.OdometerM, + ).Scan(&cp.ID, &cp.SessionID, &cp.RecordedAt, &cp.Lat, &cp.Lng, &cp.SocPct, &cp.OdometerM) + if errors.Is(err, pgx.ErrNoRows) { + // DO NOTHING yields no row on conflict: fetch the winner. + if err := s.db.Pool.QueryRow(ctx, ` + SELECT id, session_id, recorded_at, lat, lng, soc_pct, odometer_m + FROM journey_checkpoints WHERE session_id = $1 AND recorded_at = $2`, + sessionID, in.RecordedAt, + ).Scan(&cp.ID, &cp.SessionID, &cp.RecordedAt, &cp.Lat, &cp.Lng, &cp.SocPct, &cp.OdometerM); err != nil { + return nil, fmt.Errorf("journey: checkpoint conflict read: %w", err) + } + return cp, nil + } + if err != nil { + return nil, fmt.Errorf("journey: append checkpoint: %w", err) + } + return cp, nil +} + +// LatestCheckpoint returns the newest trail point, or nil. +func (s *Store) LatestCheckpoint(ctx context.Context, sessionID int64) (*Checkpoint, error) { + cp := &Checkpoint{} + err := s.db.Pool.QueryRow(ctx, ` + SELECT id, session_id, recorded_at, lat, lng, soc_pct, odometer_m + FROM journey_checkpoints WHERE session_id = $1 + ORDER BY recorded_at DESC LIMIT 1`, sessionID, + ).Scan(&cp.ID, &cp.SessionID, &cp.RecordedAt, &cp.Lat, &cp.Lng, &cp.SocPct, &cp.OdometerM) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("journey: latest checkpoint: %w", err) + } + return cp, nil +} + +// Trail returns the newest points, newest first. Limit clamped 1..100. +func (s *Store) Trail(ctx context.Context, sessionID int64, limit int) ([]*Checkpoint, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, session_id, recorded_at, lat, lng, soc_pct, odometer_m + FROM journey_checkpoints WHERE session_id = $1 + ORDER BY recorded_at DESC LIMIT $2`, sessionID, limit) + if err != nil { + return nil, fmt.Errorf("journey: trail: %w", err) + } + defer rows.Close() + out := []*Checkpoint{} + for rows.Next() { + cp := &Checkpoint{} + if err := rows.Scan(&cp.ID, &cp.SessionID, &cp.RecordedAt, &cp.Lat, &cp.Lng, &cp.SocPct, &cp.OdometerM); err != nil { + return nil, fmt.Errorf("journey: scan checkpoint: %w", err) + } + out = append(out, cp) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("journey: trail: %w", err) + } + return out, nil +} + +// RouteLeg is one completed trip's driven distance over its straight +// leg, for route learning. +type RouteLeg struct { + DistanceM float64 + StraightM float64 +} + +// RouteLegs returns recent completed trips on a route (both +// directions — the drive back teaches the drive out), newest first. +// Distance is the odometer span, so trips without odometer fixes do +// not contribute. Empty names match nothing: unnamed routes have no +// identity to learn on. +func (s *Store) RouteLegs(ctx context.Context, vehicleID int64, origin, dest string, limit int) ([]RouteLeg, error) { + if origin == "" || dest == "" { + return []RouteLeg{}, nil + } + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + rows, err := s.db.Pool.Query(ctx, ` + SELECT + (SELECT MAX(c.odometer_m) - MIN(c.odometer_m) + FROM journey_checkpoints c WHERE c.session_id = s.id) AS dist, + 6371000 * 2 * ASIN(SQRT( + POWER(SIN(RADIANS(s.dest_lat - s.origin_lat) / 2), 2) + + COS(RADIANS(s.origin_lat)) * COS(RADIANS(s.dest_lat)) * + POWER(SIN(RADIANS(s.dest_lng - s.origin_lng) / 2), 2) + )) AS straight + FROM journey_sessions s + WHERE s.vehicle_id = $1 AND s.status = 'completed' + AND s.origin_lat IS NOT NULL AND s.origin_lng IS NOT NULL + AND s.dest_lat IS NOT NULL AND s.dest_lng IS NOT NULL + AND ((s.origin_name = $2 AND s.dest_name = $3) + OR (s.origin_name = $3 AND s.dest_name = $2)) + ORDER BY s.ended_at DESC NULLS LAST LIMIT $4`, + vehicleID, origin, dest, limit) + if err != nil { + return nil, fmt.Errorf("journey: route legs: %w", err) + } + defer rows.Close() + out := []RouteLeg{} + for rows.Next() { + var dist *float64 + var straight float64 + if err := rows.Scan(&dist, &straight); err != nil { + return nil, fmt.Errorf("journey: scan route leg: %w", err) + } + if dist == nil || *dist <= 0 || straight <= 0 { + continue + } + out = append(out, RouteLeg{DistanceM: *dist, StraightM: straight}) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("journey: route legs: %w", err) + } + return out, nil +} + +// VehicleEfficiency returns the 90-day Wh/km mean for trip-capable +// drives. Kept in parity with tripplanner.vehicleEfficiency (same +// predicates and 50..500 guard) so both surfaces plan from the same +// number; ok=false falls back to the shared default. +func (s *Store) VehicleEfficiency(ctx context.Context, vehicleID int64) (eff float64, ok bool, err error) { + var v *float64 + err = s.db.Pool.QueryRow(ctx, ` + SELECT CASE + WHEN SUM(distance_m) > 0 THEN + SUM(COALESCE(energy_used_wh, 0)) * 1000.0 + / SUM(distance_m) + END + FROM drives + WHERE vehicle_id = $1 AND distance_m > 1609 + AND energy_used_wh > 0 + AND start_soc_pct > end_soc_pct + AND started_at > NOW() - INTERVAL '90 days'`, vehicleID).Scan(&v) + if err != nil { + return 0, false, fmt.Errorf("journey: efficiency: %w", err) + } + if v == nil || *v <= 50 || *v > 500 { + return 0, false, nil + } + return *v, true, nil +} diff --git a/internal/api/router.go b/internal/api/router.go index 2df87b021..6c8268956 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -402,8 +402,8 @@ import ( handlermw "github.com/ev-dev-labs/teslasync/internal/handler/middleware" v1handlers "github.com/ev-dev-labs/teslasync/internal/handler/v1" actioncenterhandler "github.com/ev-dev-labs/teslasync/internal/handler/v1/actioncenter" - fleetstatehandler "github.com/ev-dev-labs/teslasync/internal/handler/v1/fleetstate" advancedintelligencehandler "github.com/ev-dev-labs/teslasync/internal/handler/v1/advancedintelligence" + fleetstatehandler "github.com/ev-dev-labs/teslasync/internal/handler/v1/fleetstate" ownershipintelhandler "github.com/ev-dev-labs/teslasync/internal/handler/v1/ownershipintel" "github.com/ev-dev-labs/teslasync/internal/tracing" ) @@ -2233,7 +2233,50 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie vehicledb.NewVehicleRepo(db), ) waitoracleHandler := apiwaitoracle.NewHandler(apiwaitoracle.NewStore(db)) - journeyHandler := apijourney.NewHandler(apijourney.NewStore(db)) + journeyStore := apijourney.NewStore(db) + journeyHandler := apijourney.NewHandler( + journeyStore, + journeyStore, + apiwaitoracle.NewStore(db), + ) + journeyDeparture := apijourney.NewDepartureHandler( + journeyStore, + apistormguard.NewClient(), + liveStateReader, + ) + journeyChecklist := apijourney.NewChecklistHandler( + journeyStore, + journeyStore, + liveStateReader, + apistormguard.NewStore(db), + systemdb.NewSoftwareUpdateRepo(db), + ) + journeyLive := apijourney.NewLiveHandler( + journeyStore, + journeyStore, + liveStateReader, + ) + journeyReplan := apijourney.NewReplanHandler( + journeyStore, + journeyStore, + journeyStore, + apiwaitoracle.NewStore(db), + ) + journeyArrival := apijourney.NewArrivalHandler( + journeyStore, + journeyStore, + liveStateReader, + ) + journeyReport := apijourney.NewReportHandler( + journeyStore, + journeyStore, + journeyStore, + ) + journeyNudge := apijourney.NewNudgeHandler( + journeyStore, + apistormguard.NewClient(), + journeyStore, + ) searchHandler := apisearch.NewHandler(db) // Wire Redis signal cache to handlers that read live vehicle state. @@ -3880,6 +3923,17 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/complete", journeyHandler.Complete) r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/abort", journeyHandler.Abort) r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/sessions/{id}/plans", journeyHandler.SavePlan) + r.With(httprate.LimitByIP(10, 1*time.Minute)).Post("/sessions/{id}/score-stops", journeyHandler.ScoreStops) + r.Get("/sessions/{id}/departure", journeyDeparture.Advise) + r.Get("/sessions/{id}/checklist", journeyChecklist.Latest) + r.With(httprate.LimitByIP(10, 1*time.Minute)).Post("/sessions/{id}/checklist/runs", journeyChecklist.Refresh) + r.Get("/sessions/{id}/live", journeyLive.View) + r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/checkpoints", journeyLive.Append) + r.Get("/sessions/{id}/replan", journeyReplan.Assess) + r.With(httprate.LimitByIP(10, 1*time.Minute)).Post("/sessions/{id}/replan", journeyReplan.Rescore) + r.Get("/sessions/{id}/arrival", journeyArrival.Prep) + r.Get("/sessions/{id}/report", journeyReport.Card) + r.Get("/sessions/{id}/nudge", journeyNudge.Monitor) }) // Trip Planner (route planning with charging stop estimation) diff --git a/internal/api/stormguard/assess.go b/internal/api/stormguard/assess.go index 09149f0f6..845b37c34 100644 --- a/internal/api/stormguard/assess.go +++ b/internal/api/stormguard/assess.go @@ -31,6 +31,22 @@ type Assessment struct { PeakGustMS float64 `json:"peak_gust_ms"` } +// HourLevel grades one forecast hour with the same thresholds Assess +// uses over a window: warning for thunder or destructive gusts, watch +// for heavy precip or strong gusts, else none. Pure: no I/O, +// deterministic. Journey Autopilot's departure advisor ranks slots +// through it so both surfaces agree on what "severe" means. +func HourLevel(code int, gustMS float64) string { + switch { + case isThunder(code) || gustMS >= warnGustMS: + return LevelWarning + case isHeavyPrecip(code) || gustMS >= watchGustMS: + return LevelWatch + default: + return LevelNone + } +} + // Assess grades the forecast from now. Pure: no I/O, deterministic. // Only the first 72 hourly rows (3 days) are examined; the verdict // horizons are 24h (warning) and 48h (watch). diff --git a/internal/api/stormguard/assess_test.go b/internal/api/stormguard/assess_test.go index 23a0f0d5c..346a91a2e 100644 --- a/internal/api/stormguard/assess_test.go +++ b/internal/api/stormguard/assess_test.go @@ -65,3 +65,27 @@ func TestAssessPeakGust(t *testing.T) { t.Fatalf("peak = %v, want 22 (beyond-horizon gust excluded)", got.PeakGustMS) } } + +func TestHourLevel(t *testing.T) { + cases := []struct { + name string + code int + gust float64 + want string + }{ + {"thunder", 95, 5, LevelWarning}, + {"destructive gust", 1, 25, LevelWarning}, + {"heavy snow", 75, 5, LevelWatch}, + {"violent shower", 82, 5, LevelWatch}, + {"strong gust", 1, 17, LevelWatch}, + {"calm", 1, 9, LevelNone}, + {"drizzle", 51, 9, LevelNone}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := HourLevel(c.code, c.gust); got != c.want { + t.Fatalf("HourLevel(%d, %v) = %q, want %q", c.code, c.gust, got, c.want) + } + }) + } +} diff --git a/migrations/000243_journey_checklist.down.sql b/migrations/000243_journey_checklist.down.sql new file mode 100644 index 000000000..8d1064fec --- /dev/null +++ b/migrations/000243_journey_checklist.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS journey_checklist_runs; diff --git a/migrations/000243_journey_checklist.up.sql b/migrations/000243_journey_checklist.up.sql new file mode 100644 index 000000000..43d989e2b --- /dev/null +++ b/migrations/000243_journey_checklist.up.sql @@ -0,0 +1,14 @@ +-- Journey Autopilot slice 3: checklist runs. +-- +-- Each run snapshots one ready-to-roll evaluation for a session: the +-- item verdicts as JSONB plus when the check ran. Runs are append-only +-- so the trip debrief can show the vehicle's pre-trip state. + +CREATE TABLE IF NOT EXISTS journey_checklist_runs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id bigint NOT NULL REFERENCES journey_sessions (id) ON DELETE CASCADE, + run_at timestamptz NOT NULL DEFAULT now(), + items jsonb NOT NULL DEFAULT '[]' +); +CREATE INDEX IF NOT EXISTS idx_journey_checklist_runs_session + ON journey_checklist_runs (session_id, run_at DESC); diff --git a/migrations/000244_journey_checkpoints.down.sql b/migrations/000244_journey_checkpoints.down.sql new file mode 100644 index 000000000..6d1d110a4 --- /dev/null +++ b/migrations/000244_journey_checkpoints.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS journey_checkpoints; diff --git a/migrations/000244_journey_checkpoints.up.sql b/migrations/000244_journey_checkpoints.up.sql new file mode 100644 index 000000000..b9aeff58f --- /dev/null +++ b/migrations/000244_journey_checkpoints.up.sql @@ -0,0 +1,19 @@ +-- Journey Autopilot slice 4: live trail checkpoints. +-- +-- Append-only position/state snapshots while a session is active (or +-- paused). The companion posts check-ins; the server backfills any +-- missing fields from live telemetry so a bare ping still snapshots +-- the car. Unique on (session_id, recorded_at) for idempotent retry. + +CREATE TABLE IF NOT EXISTS journey_checkpoints ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id bigint NOT NULL REFERENCES journey_sessions (id) ON DELETE CASCADE, + recorded_at timestamptz NOT NULL DEFAULT now(), + lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90), + lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180), + soc_pct double precision CHECK (soc_pct IS NULL OR (soc_pct BETWEEN 0 AND 100)), + odometer_m double precision CHECK (odometer_m IS NULL OR odometer_m >= 0), + UNIQUE (session_id, recorded_at) +); +CREATE INDEX IF NOT EXISTS idx_journey_checkpoints_session + ON journey_checkpoints (session_id, recorded_at DESC); diff --git a/ops/migrations/manifest.yaml b/ops/migrations/manifest.yaml index 1ba5f3bf0..bfdb5bf0a 100644 --- a/ops/migrations/manifest.yaml +++ b/ops/migrations/manifest.yaml @@ -343,3 +343,33 @@ migrations: reversible: true reviewed_by: "@atulmgupta" reviewed_on: "2026-09-13" + + - version: 243 + name: journey_checklist + forward_compatible: true + forward_compatibility_notes: >- + New journey_checklist_runs table only. + rollback_notes: >- + Leave applied on rollback. Down drops checklist run history. + expected_duration: <1s + duration_basis: estimate + lock_risk: none + lock_details: CREATE TABLE / INDEX on new relations. + reversible: true + reviewed_by: "@atulmgupta" + reviewed_on: "2026-09-14" + + - version: 244 + name: journey_checkpoints + forward_compatible: true + forward_compatibility_notes: >- + New journey_checkpoints table only. + rollback_notes: >- + Leave applied on rollback. Down drops live trail checkpoints. + expected_duration: <1s + duration_basis: estimate + lock_risk: none + lock_details: CREATE TABLE / INDEX on new relations. + reversible: true + reviewed_by: "@atulmgupta" + reviewed_on: "2026-09-14" diff --git a/web/scripts/audit-live-mutations.mjs b/web/scripts/audit-live-mutations.mjs index fadbade2c..583885513 100644 --- a/web/scripts/audit-live-mutations.mjs +++ b/web/scripts/audit-live-mutations.mjs @@ -143,7 +143,14 @@ const MODE_INDEPENDENT_MUTATIONS = { 'hooks/useAutomations.ts': ['useInstallRoutine'], 'hooks/useBatteryCertificate.ts': ['useVerifyBatteryCertificate'], 'hooks/useComfort.ts': ['useSaveComfortConfig'], - 'hooks/useJourney.ts': ['useCreateJourney', 'useTransitionJourney'], + 'hooks/useJourney.ts': [ + 'useCreateJourney', + 'useTransitionJourney', + 'useScoreStops', + 'useRefreshChecklist', + 'useCheckIn', + 'useRequestReplan', + ], 'hooks/useStormguard.ts': ['useSaveStormguardConfig'], 'hooks/useEnergy.ts': [ 'useRefreshTeslaEnergySites', diff --git a/web/src/api/hooks/useJourney.ts b/web/src/api/hooks/useJourney.ts index 44db1204b..6d842163a 100644 --- a/web/src/api/hooks/useJourney.ts +++ b/web/src/api/hooks/useJourney.ts @@ -5,6 +5,7 @@ import { scopedPath } from '../scope'; import { safeArray } from '@/lib/safeArray'; import { useMutationToast } from './_toastHelpers'; import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; +import { isApiError } from '@/lib/resilience'; /** * Journey Autopilot: trip sessions + versioned plans. Reads the backend @@ -67,11 +68,174 @@ export interface CreateJourneyRequest { dest_lng?: number | null; } +export interface ScoreCandidate { + site: string; + lat: number; + lng: number; + arrive_at: string; +} + +export interface ScoredStop { + site: string; + score: number; + wait_s: number | null; + unit_price: number | null; + health: number | null; + corridor_m: number; + evidence: string[]; +} + +export interface StopScores { + session_id: number; + energy_wh: number; + stops: ScoredStop[]; + winner: string; + plan_version: number; +} + +export interface ScoreStopsRequest { + id: number; + candidates: ScoreCandidate[]; + energy_wh: number; +} + +export interface DepartureSlot { + depart_at: string; + level: 'none' | 'watch' | 'warning'; + score: number; +} + +export interface DepartureAdvice { + session_id: number; + slots: DepartureSlot[]; + recommended_at: string | null; + charge: { soc_pct: number | null; limit_pct: number | null } | null; + evidence: string[]; +} + +export type ChecklistStatus = 'ok' | 'attention' | 'action' | 'unknown'; + +export interface ChecklistItem { + key: string; + status: ChecklistStatus; + detail: string; +} + +export interface ChecklistRun { + id: number; + session_id: number; + run_at: string; + items: ChecklistItem[]; +} + +export interface JourneyCheckpoint { + id: number; + session_id: number; + recorded_at: string; + lat: number; + lng: number; + soc_pct: number | null; + odometer_m: number | null; +} + +export interface JourneyProgress { + total_m: number; + done_m: number; + left_m: number; +} + +export type JourneyRangeVerdict = 'ok' | 'attention' | 'action' | 'unknown'; + +export interface JourneyRange { + have_wh: number | null; + need_wh: number | null; + eff_wh_km: number | null; + verdict: JourneyRangeVerdict; +} + +export interface JourneyNextStop { + site: string; + wait_s: number | null; +} + +export interface JourneyLiveView { + session: JourneySession; + latest: JourneyCheckpoint | null; + trail: JourneyCheckpoint[]; + progress: JourneyProgress | null; + range: JourneyRange; + next: JourneyNextStop | null; + evidence: string[]; +} + +export type JourneyDeviationVerdict = 'on_track' | 'drifted' | 'off_route' | 'unknown'; + +export interface JourneyReplanAssessment { + session_id: number; + deviation: { deviation_m: number | null; verdict: JourneyDeviationVerdict }; + latest: JourneyCheckpoint | null; + evidence: string[]; +} + +export interface JourneyArrival { + session_id: number; + dest_name: string; + left_m: number | null; + pace_ms: number | null; + eta_at: string | null; + moving: boolean; + verdict: ChecklistStatus; + shortfall_wh: number | null; + route_factor: number | null; + route_trips: number; + evidence: string[]; +} + +export interface JourneyChecklistRecap { + ready: number; + total: number; +} + +export interface JourneyReport { + session_id: number; + status: JourneyStatus; + started_at: string | null; + ended_at: string | null; + duration_s: number | null; + distance_m: number | null; + fixes: number; + plans: number; + replans: number; + detour: number | null; + route_factor: number | null; + route_trips: number; + checklist: JourneyChecklistRecap | null; + evidence: string[]; +} + +export type JourneyNudgeVerdict = 'leave_now' | 'wait' | 'delay' | 'unknown'; + +export interface JourneyNudge { + session_id: number; + verdict: JourneyNudgeVerdict; + slot_at: string | null; + blockers: ChecklistItem[]; + evidence: string[]; +} + export const journeyKeys = { all: ['journey'] as const, list: (vehicleId: number | null, status: string) => ['journey', 'sessions', vehicleId, status] as const, detail: (id: number | null) => ['journey', 'session', id] as const, + departure: (id: number | null, from: string | null, to: string | null) => + ['journey', 'departure', id, from, to] as const, + checklist: (id: number | null) => ['journey', 'checklist', id] as const, + live: (id: number | null) => ['journey', 'live', id] as const, + replan: (id: number | null) => ['journey', 'replan', id] as const, + arrival: (id: number | null) => ['journey', 'arrival', id] as const, + report: (id: number | null) => ['journey', 'report', id] as const, + nudge: (id: number | null) => ['journey', 'nudge', id] as const, }; function isValidVehicle(vehicleId: number | null | undefined): vehicleId is number { @@ -137,6 +301,190 @@ export function useCreateJourney() { export type JourneyTransition = 'start' | 'pause' | 'resume' | 'complete' | 'abort'; +/** Ranks candidate stops on wait, price, health, and corridor deviation. */ +export function useScoreStops() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ id, candidates, energy_wh }: ScoreStopsRequest) => + request(`/journey/sessions/${id}/score-stops`, { + method: 'POST', + body: JSON.stringify({ candidates, energy_wh }), + }), + onSuccess: (scores) => { + invalidateAndBroadcast(qc, { queryKey: journeyKeys.detail(scores.session_id) }); + success('toast.journey.score.success', 'Stops scored — {{winner}} wins', { + winner: scores.winner, + }); + }, + onError: (err) => error(err, 'toast.journey.score.error', 'Failed to score stops'), + }); +} + +/** Ranks departure slots for a session. from/to are RFC3339; null = server default. */ +export function useDeparture( + id: number | null | undefined, + from: string | null, + to: string | null, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: journeyKeys.departure(id ?? null, from, to), + queryFn: ({ signal }) => + request( + scopedPath(`/journey/sessions/${id}/departure`, { + filters: { from, to }, + }), + { signal }, + ), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('operational'), + }); +} + +/** Reads the latest checklist run for a session. */ +export function useChecklist(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.checklist(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}/checklist`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('operational'), + }); +} + +/** Evaluates readiness live and persists the run. */ +export function useRefreshChecklist() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (id: number) => + request(`/journey/sessions/${id}/checklist/runs`, { method: 'POST' }), + onSuccess: (run) => { + invalidateAndBroadcast(qc, { queryKey: journeyKeys.checklist(run.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.nudge(run.session_id) }); + const blocking = run.items.filter((i) => i.status === 'action').length; + success( + blocking === 0 + ? 'toast.journey.checklist.clear' + : 'toast.journey.checklist.attention', + blocking === 0 ? 'Ready to roll' : '{{count}} items need attention', + { count: blocking }, + ); + }, + onError: (err) => error(err, 'toast.journey.checklist.error', 'Checklist failed'), + }); +} + +/** Reads the glanceable live snapshot for a session (progress, range, trail). */ +export function useJourneyLive(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.live(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}/live`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('live'), + }); +} + +/** + * Snapshots one trail point; the server backfills missing fields from + * live telemetry. `recorded_at` replays an outbox entry under its + * original instant (the server dedupes idempotently). + * + * Network failures stay silent here BY CONTRACT: the check-in outbox + * hook queues them instead of toasting, so only ApiErrors toast. + */ +export function useCheckIn() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ id, recorded_at }: { id: number; recorded_at?: string }) => + request(`/journey/sessions/${id}/checkpoints`, { + method: 'POST', + body: JSON.stringify(recorded_at == null ? {} : { recorded_at }), + }), + onSuccess: (checkpoint) => { + invalidateAndBroadcast(qc, { queryKey: journeyKeys.live(checkpoint.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.replan(checkpoint.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.arrival(checkpoint.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.report(checkpoint.session_id) }); + success('toast.journey.checkin.success', 'Checked in'); + }, + onError: (err) => { + if (!isApiError(err)) return; + error(err, 'toast.journey.checkin.error', 'Check-in failed'); + }, + }); +} + +/** Reads the corridor-deviation assessment for a session. */ +export function useReplanAssessment(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.replan(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}/replan`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('operational'), + }); +} + +/** Re-ranks the saved candidates from the latest fix; persists a replan version. */ +export function useRequestReplan() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ id, energy_wh }: { id: number; energy_wh?: number }) => + request(`/journey/sessions/${id}/replan`, { + method: 'POST', + body: JSON.stringify(energy_wh == null ? {} : { energy_wh }), + }), + onSuccess: (scores) => { + invalidateAndBroadcast(qc, { queryKey: journeyKeys.detail(scores.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.live(scores.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.replan(scores.session_id) }); + invalidateAndBroadcast(qc, { queryKey: journeyKeys.report(scores.session_id) }); + success('toast.journey.replan.success', 'Replanned — {{winner}} wins', { + winner: scores.winner, + }); + }, + onError: (err) => error(err, 'toast.journey.replan.error', 'Replan failed'), + }); +} + +/** Reads the arrival prep snapshot (ETA from recent pace plus charge advice). */ +export function useArrival(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.arrival(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}/arrival`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('live'), + }); +} + +/** Reads the debrief card for a session (a "so far" card while live). */ +export function useReport(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.report(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}/report`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('operational'), + }); +} + +/** Reads the leave-now nudge for a planned session. */ +export function useNudge(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.nudge(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}/nudge`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('operational'), + }); +} + /** Moves a session along its status machine. */ export function useTransitionJourney() { const qc = useQueryClient(); diff --git a/web/src/features/trips/components/ArrivalPanel.test.tsx b/web/src/features/trips/components/ArrivalPanel.test.tsx new file mode 100644 index 000000000..ce5a95fe3 --- /dev/null +++ b/web/src/features/trips/components/ArrivalPanel.test.tsx @@ -0,0 +1,145 @@ +/** + * ArrivalPanel — behaviour coverage. + * + * The data hook (`useArrival`) and `useUnits` are mocked and driven + * per test; shared UI (Badge, ListSkeleton, QueryError) is REAL so the + * render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +// ── i18n stub ── +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +// ── data hooks, driven per test ── +vi.mock('@/api/hooks/useJourney', () => ({ + useArrival: vi.fn(), +})); + +vi.mock('@/hooks/useUnits', () => ({ + useUnits: () => ({ + formatDistance: (m: number) => `${m} m`, + formatSpeed: (mps: number) => `${mps} m/s`, + formatEnergy: (wh: number) => `${wh} Wh`, + }), +})); + +import { useArrival, type JourneySession } from '@/api/hooks/useJourney'; +import { ArrivalPanel } from './ArrivalPanel'; + +const mockArrival = useArrival as unknown as ReturnType; + +const session: JourneySession = { + id: 1, vehicle_id: 7, name: 'Denver run', + origin_name: 'Denver', origin_lat: 39.7392, origin_lng: -104.9903, + dest_name: 'KC', dest_lat: 39.0997, dest_lng: -94.5786, + status: 'active', plan_version: 1, + created_at: '2026-09-14T08:00:00Z', updated_at: '2026-09-14T10:00:00Z', + started_at: '2026-09-14T08:05:00Z', ended_at: null, +}; + +const arrival = { + session_id: 1, + dest_name: 'KC', + left_m: 450000, + pace_ms: 25, + eta_at: '2026-09-14T15:00:00Z', + moving: true, + verdict: 'action', + shortfall_wh: 15700, + route_factor: 1.05, + route_trips: 2, + evidence: ['450 km to KC', 'moving at pace', 'top up ≈ 15.7 kWh en route to hold the buffer'], +}; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, isError: false, + isPending: false, fetchStatus: 'idle', dataUpdatedAt: Date.now(), + error: null, refetch: vi.fn(), ...extra, + }; +} + +function renderPanel() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockArrival.mockReturnValue(idle({ data: arrival })); +}); + +describe('ArrivalPanel', () => { + it('renders ETA, pace, distance, advice, and evidence', () => { + renderPanel(); + expect(screen.getByText('ETA')).toBeInTheDocument(); + expect(screen.getByText(/\d{1,2}:\d{2}/)).toBeInTheDocument(); + expect(screen.getByText(/moving\s+·/)).toBeInTheDocument(); + expect(screen.getByText(/25 m\/s/)).toBeInTheDocument(); + expect(screen.getByText(/450000 m/)).toBeInTheDocument(); + expect(screen.getByText('Top up en route')).toBeInTheDocument(); + expect(screen.getByText(/top up ≈ 15700 Wh en route/)).toBeInTheDocument(); + expect(screen.getByText('adjusted 1.05× from 2 trips')).toBeInTheDocument(); + expect(screen.getByText(/hold the buffer/)).toBeInTheDocument(); + }); + + it('reads parked without an ETA and omits the shortfall when covered', () => { + mockArrival.mockReturnValue( + idle({ + data: { + ...arrival, + pace_ms: 0, + eta_at: null, + moving: false, + verdict: 'ok', + shortfall_wh: null, + }, + }), + ); + renderPanel(); + expect(screen.getByText(/parked/)).toBeInTheDocument(); + expect(screen.getByText('Arrive with buffer')).toBeInTheDocument(); + expect(screen.queryByText(/top up ≈ 15700 Wh en route$/)).not.toBeInTheDocument(); + }); + + it('surfaces failures with a retry path', () => { + const refetch = vi.fn(); + mockArrival.mockReturnValue(idle({ error: new Error('db down'), isError: true, refetch })); + renderPanel(); + fireEvent.click(screen.getByText('Retry')); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/web/src/features/trips/components/ArrivalPanel.tsx b/web/src/features/trips/components/ArrivalPanel.tsx new file mode 100644 index 000000000..49764c0d7 --- /dev/null +++ b/web/src/features/trips/components/ArrivalPanel.tsx @@ -0,0 +1,123 @@ +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; +import { useArrival, type ChecklistStatus, type JourneySession } from '@/api/hooks/useJourney'; +import { useDataState } from '@/hooks/useDataState'; +import { useUnits } from '@/hooks/useUnits'; +import { Badge, Text } from '@/components/ui'; +import { ListSkeleton, QueryError } from '@/components/feedback'; +import { formatTime } from '@/lib/dateFormat'; +import { fmtNumber } from '@/lib/numberFormat'; + +const VERDICT_LABEL_KEYS: Record = { + ok: 'journey.arrival.verdict.ok', + attention: 'journey.arrival.verdict.attention', + action: 'journey.arrival.verdict.action', + unknown: 'journey.arrival.verdict.unknown', +}; + +const VERDICT_DEFAULTS: Record = { + ok: 'Arrive with buffer', + attention: 'Arrive tight', + action: 'Top up en route', + unknown: 'Unknown', +}; + +function verdictVariant(verdict: ChecklistStatus) { + switch (verdict) { + case 'ok': + return 'success' as const; + case 'attention': + return 'warning' as const; + case 'action': + return 'danger' as const; + default: + return 'neutral' as const; + } +} + +/** + * Arrival prep: ETA from recent pace plus the charge advice for the + * destination. Read-only — the numbers refresh on the ambient live + * tick and on every check-in. + */ +export function ArrivalPanel({ session }: { session: JourneySession }) { + const { t } = useTranslation(); + const units = useUnits(); + + const arrivalQuery = useArrival(session.id); + const arrivalState = useDataState(arrivalQuery); + const arrival = arrivalQuery.data ?? null; + + return ( +
+ + + + {arrivalQuery.isLoading ? ( + + ) : arrivalState.fatalError ? ( + arrivalState.retry?.()} /> + ) : arrival == null ? ( + + {t('journey.arrival.empty', 'No arrival reading yet.')} + + ) : ( +
+
+ + {t('journey.arrival.eta', 'ETA')} + + + {arrival.eta_at != null ? formatTime(arrival.eta_at) : '—'} + + + {arrival.moving + ? t('journey.arrival.moving', 'moving') + : t('journey.arrival.parked', 'parked')} + {arrival.pace_ms != null ? ` · ${units.formatSpeed(arrival.pace_ms)}` : null} + {arrival.left_m != null ? ` · ${units.formatDistance(arrival.left_m)}` : null} + +
+ +
+ + {t(VERDICT_LABEL_KEYS[arrival.verdict], VERDICT_DEFAULTS[arrival.verdict])} + + {arrival.shortfall_wh != null ? ( + + {t('journey.arrival.shortfall', 'top up ≈ {{energy}} en route', { + energy: units.formatEnergy(arrival.shortfall_wh), + })} + + ) : null} + {arrival.route_factor != null ? ( + + {t('journey.arrival.adjusted', 'adjusted {{ratio}}× from {{count}} trips', { + ratio: fmtNumber(arrival.route_factor, 2), + count: arrival.route_trips, + })} + + ) : null} +
+ + {arrival.evidence.length > 0 ? ( +
    + {arrival.evidence.map((line) => ( + + · {line} + + ))} +
+ ) : null} +
+ )} +
+ ); +} diff --git a/web/src/features/trips/components/ChecklistPanel.test.tsx b/web/src/features/trips/components/ChecklistPanel.test.tsx new file mode 100644 index 000000000..975fceea1 --- /dev/null +++ b/web/src/features/trips/components/ChecklistPanel.test.tsx @@ -0,0 +1,143 @@ +/** + * ChecklistPanel — behaviour coverage. + * + * Data hooks (`useChecklist` / `useRefreshChecklist`) are mocked and + * driven per test; shared UI (Badge, Button, EmptyState, ListSkeleton, + * QueryError) is REAL so the render-boundary wiring is genuinely + * exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +// ── i18n stub ── +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +// ── data hooks, driven per test ── +vi.mock('@/api/hooks/useJourney', () => ({ + useChecklist: vi.fn(), + useRefreshChecklist: vi.fn(), +})); + +import { useChecklist, useRefreshChecklist, type JourneySession } from '@/api/hooks/useJourney'; +import { ChecklistPanel } from './ChecklistPanel'; + +const mockRun = useChecklist as unknown as ReturnType; +const mockRefresh = useRefreshChecklist as unknown as ReturnType; + +const session: JourneySession = { + id: 1, vehicle_id: 7, name: 'Tahoe ski trip', + origin_name: 'Home', origin_lat: 37.4, origin_lng: -122.1, + dest_name: 'Tahoe', dest_lat: 39.1, dest_lng: -120.0, + status: 'planned', plan_version: 0, + created_at: '2026-09-10T10:00:00Z', updated_at: '2026-09-10T10:00:00Z', + started_at: null, ended_at: null, +}; + +const run = { + id: 3, + session_id: 1, + run_at: '2026-09-14T09:00:00Z', + items: [ + { key: 'charge_level', status: 'ok', detail: '85% (trip-ready is 80%+)' }, + { key: 'charge_limit', status: 'attention', detail: 'limit 80% (raise to 85%+ for trips)' }, + { key: 'tire_pressure', status: 'action', detail: 'lowest FR at 2.6 bar (placard 2.9)' }, + { key: 'storm', status: 'ok', detail: 'no severe weather on record' }, + { key: 'software_update', status: 'unknown', detail: 'no update on record' }, + ], +}; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, isError: false, + isPending: false, fetchStatus: 'idle', dataUpdatedAt: Date.now(), + error: null, refetch: vi.fn(), ...extra, + }; +} + +class Api404 extends Error { + status = 404; + constructor() { + super('HTTP 404'); + this.name = 'ApiError'; + } +} + +function renderPanel() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockRun.mockReturnValue(idle({ data: run })); + mockRefresh.mockReturnValue({ mutate: vi.fn(), isPending: false }); +}); + +describe('ChecklistPanel', () => { + it('renders items with localized labels and statuses', () => { + renderPanel(); + expect(screen.getByText('Charge level')).toBeInTheDocument(); + expect(screen.getByText('Tire pressure')).toBeInTheDocument(); + expect(screen.getAllByText('Ready').length).toBeGreaterThan(0); + expect(screen.getByText('Fix now')).toBeInTheDocument(); + expect(screen.getByText('lowest FR at 2.6 bar (placard 2.9)')).toBeInTheDocument(); + }); + + it('refreshes the run for the session', () => { + const mutate = vi.fn(); + mockRefresh.mockReturnValue({ mutate, isPending: false }); + renderPanel(); + fireEvent.click(screen.getByText('Re-check')); + expect(mutate).toHaveBeenCalledWith(1); + }); + + it('treats never-ran as an empty state with a run action', () => { + mockRun.mockReturnValue(idle({ data: undefined, error: new Api404(), isError: true })); + const mutate = vi.fn(); + mockRefresh.mockReturnValue({ mutate, isPending: false }); + renderPanel(); + expect(screen.getByText(/No checks yet/)).toBeInTheDocument(); + fireEvent.click(screen.getByText('Run checklist')); + expect(mutate).toHaveBeenCalledWith(1); + }); + + it('surfaces non-404 failures with a retry path', () => { + const refetch = vi.fn(); + mockRun.mockReturnValue(idle({ error: new Error('db down'), isError: true, refetch })); + renderPanel(); + fireEvent.click(screen.getByText('Retry')); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/web/src/features/trips/components/ChecklistPanel.tsx b/web/src/features/trips/components/ChecklistPanel.tsx new file mode 100644 index 000000000..f756671bc --- /dev/null +++ b/web/src/features/trips/components/ChecklistPanel.tsx @@ -0,0 +1,159 @@ +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; +import { + useChecklist, + useRefreshChecklist, + type ChecklistStatus, + type JourneySession, +} from '@/api/hooks/useJourney'; +import { useDataState } from '@/hooks/useDataState'; +import { Badge, Button, Text } from '@/components/ui'; +import { EmptyState, ListSkeleton, QueryError } from '@/components/feedback'; +import { isApiError } from '@/lib/resilience'; +import { formatDateTime } from '@/lib/dateFormat'; + +const ITEM_LABEL_KEYS = { + charge_level: 'journey.checklist.item.charge_level', + charge_limit: 'journey.checklist.item.charge_limit', + tire_pressure: 'journey.checklist.item.tire_pressure', + storm: 'journey.checklist.item.storm', + software_update: 'journey.checklist.item.software_update', +} as const; + +const ITEM_DEFAULTS = { + charge_level: 'Charge level', + charge_limit: 'Charge limit', + tire_pressure: 'Tire pressure', + storm: 'Storm', + software_update: 'Software update', +} as const; + +const STATUS_LABEL_KEYS = { + ok: 'journey.checklist.status.ok', + attention: 'journey.checklist.status.attention', + action: 'journey.checklist.status.action', + unknown: 'journey.checklist.status.unknown', +} as const; + +const STATUS_DEFAULTS = { + ok: 'Ready', + attention: 'Check', + action: 'Fix now', + unknown: 'Unknown', +} as const; + +function statusVariant(status: ChecklistStatus) { + switch (status) { + case 'ok': + return 'success' as const; + case 'attention': + return 'warning' as const; + case 'action': + return 'danger' as const; + default: + return 'neutral' as const; + } +} + +/** + * Ready-to-roll checklist: live readiness verdicts (charge, tires, + * storm, update) with a persisted run history. A 404 from the latest + * endpoint means the checklist never ran — an empty state with a run + * button, not an error. + */ +export function ChecklistPanel({ session }: { session: JourneySession }) { + const { t } = useTranslation(); + + const runQuery = useChecklist(session.id); + const runState = useDataState(runQuery); + const run = runQuery.data ?? null; + + const refresh = useRefreshChecklist(); + + const neverRan = + runState.fatalError != null && + isApiError(runState.fatalError) && + runState.fatalError.status === 404; + + return ( +
+
+ + + {run != null ? ( + + ) : null} +
+ + {runQuery.isLoading || refresh.isPending ? ( + + ) : neverRan || (run == null && !runState.fatalError) ? ( +
+ ); +} diff --git a/web/src/features/trips/components/DeparturePanel.test.tsx b/web/src/features/trips/components/DeparturePanel.test.tsx new file mode 100644 index 000000000..63397b67f --- /dev/null +++ b/web/src/features/trips/components/DeparturePanel.test.tsx @@ -0,0 +1,131 @@ +/** + * DeparturePanel — behaviour coverage. + * + * The data hook (`useDeparture`) is mocked and driven per test; shared + * UI (Badge, Button, ListSkeleton, QueryError) is REAL so the + * render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +// ── i18n stub ── +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +// ── data hook, driven per test ── +vi.mock('@/api/hooks/useJourney', () => ({ + useDeparture: vi.fn(), +})); + +import { useDeparture, type JourneySession } from '@/api/hooks/useJourney'; +import { DeparturePanel } from './DeparturePanel'; + +const mockDeparture = useDeparture as unknown as ReturnType; + +const session: JourneySession = { + id: 1, vehicle_id: 7, name: 'Tahoe ski trip', + origin_name: 'Home', origin_lat: 37.4, origin_lng: -122.1, + dest_name: 'Tahoe', dest_lat: 39.1, dest_lng: -120.0, + status: 'planned', plan_version: 0, + created_at: '2026-09-10T10:00:00Z', updated_at: '2026-09-10T10:00:00Z', + started_at: null, ended_at: null, +}; + +const advice = { + session_id: 1, + recommended_at: '2026-09-14T10:00:00Z', + charge: { soc_pct: 82, limit_pct: 90 }, + evidence: ['6 slots scored, 2 warning, 1 watch'], + slots: [ + { depart_at: '2026-09-14T10:00:00Z', level: 'none', score: 100 }, + { depart_at: '2026-09-14T11:00:00Z', level: 'warning', score: 0 }, + { depart_at: '2026-09-14T12:00:00Z', level: 'watch', score: 50 }, + ], +}; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, isError: false, + isPending: false, fetchStatus: 'idle', dataUpdatedAt: Date.now(), + error: null, refetch: vi.fn(), ...extra, + }; +} + +function renderPanel(s: JourneySession = session) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDeparture.mockReturnValue(idle({ data: advice })); +}); + +describe('DeparturePanel', () => { + it('asks for an origin when the session has none', () => { + renderPanel({ ...session, origin_lat: null }); + expect(screen.getByText(/Add an origin to advise/)).toBeInTheDocument(); + expect(mockDeparture.mock.calls[0][3]).toEqual({ enabled: false }); + }); + + it('recommends the calm slot with charge context and evidence', () => { + renderPanel(); + expect(screen.getByText(/Leave /)).toBeInTheDocument(); + expect(screen.getByText('Battery 82% now')).toBeInTheDocument(); + expect(screen.getByText(/6 slots scored, 2 warning, 1 watch/)).toBeInTheDocument(); + }); + + it('re-queries when the window changes', () => { + renderPanel(); + fireEvent.click(screen.getByText('24h')); + const [, from, to] = mockDeparture.mock.lastCall as [number, string, string, { enabled?: boolean }]; + expect(new Date(to).getTime() - new Date(from).getTime()).toBe(24 * 3600_000); + }); + + it('warns when every hour warns', () => { + mockDeparture.mockReturnValue(idle({ + data: { ...advice, recommended_at: null }, + })); + renderPanel(); + expect(screen.getByText('Every hour warns — delay if you can')).toBeInTheDocument(); + }); + + it('surfaces failures with a retry path', () => { + const refetch = vi.fn(); + mockDeparture.mockReturnValue(idle({ error: new Error('meteo down'), isError: true, refetch })); + renderPanel(); + fireEvent.click(screen.getByText('Retry')); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/web/src/features/trips/components/DeparturePanel.tsx b/web/src/features/trips/components/DeparturePanel.tsx new file mode 100644 index 000000000..05e914a4b --- /dev/null +++ b/web/src/features/trips/components/DeparturePanel.tsx @@ -0,0 +1,135 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; +import { useDeparture, type JourneySession } from '@/api/hooks/useJourney'; +import { useDataState } from '@/hooks/useDataState'; +import { Badge, Button, Text } from '@/components/ui'; +import { ListSkeleton, QueryError } from '@/components/feedback'; +import { formatTime } from '@/lib/dateFormat'; +import { fmtNumber } from '@/lib/numberFormat'; + +const HORIZONS = [12, 24, 48] as const; + +function levelVariant(level: string) { + switch (level) { + case 'warning': + return 'danger' as const; + case 'watch': + return 'warning' as const; + default: + return 'success' as const; + } +} + +/** + * Departure advisor: hourly slots ranked on forecast severity at the + * origin, with the earliest calm hour recommended. Charge context + * rides along when the vehicle has reported recently. + */ +export function DeparturePanel({ session }: { session: JourneySession }) { + const { t } = useTranslation(); + const [horizonH, setHorizonH] = useState<(typeof HORIZONS)[number]>(12); + + const window = useMemo(() => { + const from = new Date(); + return { from: from.toISOString(), to: new Date(from.getTime() + horizonH * 3600_000).toISOString() }; + }, [horizonH]); + + const hasOrigin = session.origin_lat != null && session.origin_lng != null; + + const adviceQuery = useDeparture(session.id, window.from, window.to, { + enabled: hasOrigin, + }); + const adviceState = useDataState(adviceQuery); + const advice = adviceQuery.data ?? null; + + if (!hasOrigin) { + return ( + + {t( + 'journey.departure.noOrigin', + 'Add an origin to advise departure hours for this journey.', + )} + + ); + } + + return ( +
+
+ + +
+ {HORIZONS.map((h) => ( + + ))} +
+
+ + {adviceQuery.isLoading ? ( + + ) : adviceState.fatalError ? ( + adviceState.retry?.()} /> + ) : advice == null || advice.slots.length === 0 ? ( + + {t('journey.departure.uncovered', 'The forecast covers none of this window.')} + + ) : ( +
+ {advice.recommended_at != null ? ( +
+ + {t('journey.departure.leaveAt', 'Leave {{time}}', { + time: formatTime(advice.recommended_at), + })} + + {advice.charge?.soc_pct != null ? ( + + {t('journey.departure.socNow', 'Battery {{pct}}% now', { + pct: fmtNumber(advice.charge.soc_pct, 0), + })} + + ) : null} +
+ ) : ( + + {t('journey.departure.allWarn', 'Every hour warns — delay if you can')} + + )} +
+ {advice.slots.map((slot) => ( + + {formatTime(slot.depart_at)} + + ))} +
+
    + {advice.evidence.map((line) => ( + + · {line} + + ))} +
+
+ )} +
+ ); +} diff --git a/web/src/features/trips/components/JourneyPanel.test.tsx b/web/src/features/trips/components/JourneyPanel.test.tsx index ecbc03b41..c0df7c858 100644 --- a/web/src/features/trips/components/JourneyPanel.test.tsx +++ b/web/src/features/trips/components/JourneyPanel.test.tsx @@ -44,6 +44,23 @@ vi.mock('@/api/hooks/useJourney', () => ({ useJourney: vi.fn(), useCreateJourney: vi.fn(), useTransitionJourney: vi.fn(), + useScoreStops: vi.fn(), + useDeparture: vi.fn(), + useChecklist: vi.fn(), + useRefreshChecklist: vi.fn(), + useJourneyLive: vi.fn(), + useCheckIn: vi.fn(), + useReplanAssessment: vi.fn(), + useRequestReplan: vi.fn(), + useArrival: vi.fn(), + useReport: vi.fn(), + useNudge: vi.fn(), +})); + +// StopScorePanel mounts inside the detail view; its site directory stays +// inert here so these tests keep asserting only JourneyPanel behaviour. +vi.mock('@/api/hooks/useCharging', () => ({ + useWaitOracleSites: vi.fn(), })); import { @@ -51,13 +68,37 @@ import { useJourney, useCreateJourney, useTransitionJourney, + useScoreStops, + useDeparture, + useChecklist, + useRefreshChecklist, + useJourneyLive, + useCheckIn, + useReplanAssessment, + useRequestReplan, + useArrival, + useReport, + useNudge, } from '@/api/hooks/useJourney'; +import { useWaitOracleSites } from '@/api/hooks/useCharging'; import { JourneyPanel } from './JourneyPanel'; const mockList = useJourneys as unknown as ReturnType; const mockDetail = useJourney as unknown as ReturnType; const mockCreate = useCreateJourney as unknown as ReturnType; const mockTransition = useTransitionJourney as unknown as ReturnType; +const mockScore = useScoreStops as unknown as ReturnType; +const mockSites = useWaitOracleSites as unknown as ReturnType; +const mockDeparture = useDeparture as unknown as ReturnType; +const mockChecklist = useChecklist as unknown as ReturnType; +const mockRefreshChecklist = useRefreshChecklist as unknown as ReturnType; +const mockLive = useJourneyLive as unknown as ReturnType; +const mockCheckIn = useCheckIn as unknown as ReturnType; +const mockReplanAssess = useReplanAssessment as unknown as ReturnType; +const mockReplan = useRequestReplan as unknown as ReturnType; +const mockArrival = useArrival as unknown as ReturnType; +const mockReport = useReport as unknown as ReturnType; +const mockNudge = useNudge as unknown as ReturnType; const sessions = [ { @@ -111,6 +152,18 @@ beforeEach(() => { mockDetail.mockReturnValue(idle()); mockCreate.mockReturnValue({ mutate: vi.fn(), isPending: false }); mockTransition.mockReturnValue({ mutate: vi.fn(), isPending: false }); + mockScore.mockReturnValue({ mutate: vi.fn(), isPending: false, data: undefined }); + mockSites.mockReturnValue(idle({ data: [] })); + mockDeparture.mockReturnValue(idle()); + mockChecklist.mockReturnValue(idle()); + mockRefreshChecklist.mockReturnValue({ mutate: vi.fn(), isPending: false }); + mockLive.mockReturnValue(idle()); + mockCheckIn.mockReturnValue({ mutate: vi.fn(), isPending: false }); + mockReplanAssess.mockReturnValue(idle()); + mockReplan.mockReturnValue({ mutate: vi.fn(), isPending: false, data: undefined }); + mockArrival.mockReturnValue(idle()); + mockReport.mockReturnValue(idle()); + mockNudge.mockReturnValue(idle()); }); describe('JourneyPanel', () => { diff --git a/web/src/features/trips/components/JourneyPanel.tsx b/web/src/features/trips/components/JourneyPanel.tsx index eb39cc2c6..e96c282a1 100644 --- a/web/src/features/trips/components/JourneyPanel.tsx +++ b/web/src/features/trips/components/JourneyPanel.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useState } from 'react'; +import { type FormEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icons } from '@/lib/icons'; import { @@ -15,6 +15,14 @@ import { Badge, Button, DataTable, GlassPanel, Input, PanelTitle, Select, Text } import type { Column } from '@/components/ui'; import { EmptyState, ListSkeleton, QueryError } from '@/components/feedback'; import { formatDateTime } from '@/lib/dateFormat'; +import { StopScorePanel } from './StopScorePanel'; +import { DeparturePanel } from './DeparturePanel'; +import { ChecklistPanel } from './ChecklistPanel'; +import { NudgePanel } from './NudgePanel'; +import { LiveTripPanel } from './LiveTripPanel'; +import { ReplanPanel } from './ReplanPanel'; +import { ArrivalPanel } from './ArrivalPanel'; +import { ReportPanel } from './ReportPanel'; const STATUS_FILTERS = ['', 'planned', 'active', 'paused', 'completed', 'aborted'] as const; @@ -110,6 +118,11 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) { const create = useCreateJourney(); const transition = useTransitionJourney(); + useEffect(() => { + if (selectedId != null && sessions.some((s) => s.id === selectedId)) return; + setSelectedId(sessions[0]?.id ?? null); + }, [sessions, selectedId]); + const statusLabel = (status: JourneyStatus) => t(STATUS_LABEL_KEYS[status], STATUS_LABEL_DEFAULTS[status]); @@ -209,16 +222,18 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) { }))} onChange={(event) => setStatusFilter(event.target.value)} /> - + {sessions.length > 0 || formOpen ? ( + + ) : null} @@ -261,12 +276,16 @@ export function JourneyPanel({ vehicleId }: { vehicleId: number | null }) { ) : listState.fatalError ? ( listState.retry?.()} /> ) : sessions.length === 0 ? ( -