From fdc3f30b05de8e3b10af876b9d017487a825f534 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 7 Sep 2026 11:10:36 -0700 Subject: [PATCH 01/60] feat(charging): overlay Tesla bills, fix energy baseline Two related charging-accuracy fixes: 1. Charge energy delta baselines now use State() strictly before StartedAt instead of at StartedAt, since Fleet Telemetry can emit the session-start batch after energy has already begun accumulating, causing undercounted energy in both the live handler and the completed-session tracker. 2. New Tesla Supercharger invoice overlay: ChargingHandler.Get resolves the vehicle's VIN and matches it against tesla_charging_history via a new FindBestMatch repo method (closest charge_start_datetime within a 2h window). When a match is found, billed energy/cost/currency/rate are added to the response and preferred for display on the frontend KPI tiles, with vehicle-measured energy shown as a secondary subtitle. --- internal/api/charging/handler.go | 94 +++++++++++++- internal/api/charging/handler_test.go | 119 +++++++++++++++++- .../telemetry_sessions_charge_tracking.go | 27 +++- ...telemetry_sessions_charge_tracking_test.go | 25 ++++ .../database/tesla/charging_history_repo.go | 50 +++++++- .../tesla/charging_history_repo_test.go | 50 ++++++++ web/src/api/types.ts | 7 ++ .../pages/ChargingDetailPage.test.tsx | 25 ++++ .../charging/pages/ChargingDetailPage.tsx | 70 +++++++---- web/src/i18n/en.json | 3 + web/src/i18n/en/locale-charging.json | 3 + web/src/types/charging.ts | 5 + 12 files changed, 437 insertions(+), 41 deletions(-) diff --git a/internal/api/charging/handler.go b/internal/api/charging/handler.go index bc9d5e231e..b84bfcd0b1 100644 --- a/internal/api/charging/handler.go +++ b/internal/api/charging/handler.go @@ -12,7 +12,11 @@ import ( "github.com/ev-dev-labs/teslasync/internal/api/httpx" "github.com/ev-dev-labs/teslasync/internal/database" chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" + tesladb "github.com/ev-dev-labs/teslasync/internal/database/tesla" + vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle" chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging" + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" "github.com/ev-dev-labs/teslasync/internal/signal" "github.com/rs/zerolog/log" "go.opentelemetry.io/otel" @@ -33,6 +37,8 @@ type ChargingHandler struct { charging chargingByIDFetcher state signal.StateReader live signal.LiveStateReader + vehicles vehicleVINReader + teslaBills teslaBillFinder forwardAuthHeader string // bulkOverride lets tests substitute the bulk store without standing up a // real *chargingdb.ChargingRepo. Always nil in production. @@ -47,6 +53,24 @@ type chargingByIDFetcher interface { GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error) } +// vehicleVINReader is the narrow VIN lookup used to match Supercharger +// invoices onto a measured charging session. *vehicledb.VehicleRepo +// satisfies it; tests leave it nil to skip billed overlay. +type vehicleVINReader interface { + GetByID(ctx context.Context, id int64) (*vehiclemodel.Vehicle, error) +} + +// teslaBillFinder locates the Tesla charging-history invoice that overlaps +// a measured session. *tesladb.TeslaChargingHistoryRepo satisfies it. +type teslaBillFinder interface { + FindBestMatch(ctx context.Context, vin string, startedAt time.Time) (*teslamodel.TeslaChargingHistoryEntry, error) +} + +var ( + _ vehicleVINReader = (*vehicledb.VehicleRepo)(nil) + _ teslaBillFinder = (*tesladb.TeslaChargingHistoryRepo)(nil) +) + func NewChargingHandler(db *database.DB, state signal.StateReader, live signal.LiveStateReader) *ChargingHandler { repo := chargingdb.NewChargingRepo(db) return &ChargingHandler{ @@ -55,6 +79,8 @@ func NewChargingHandler(db *database.DB, state signal.StateReader, live signal.L charging: repo, state: state, live: live, + vehicles: vehicledb.NewVehicleRepo(db), + teslaBills: tesladb.NewTeslaChargingHistoryRepo(db), } } @@ -154,14 +180,14 @@ func (h *ChargingHandler) Get(w http.ResponseWriter, r *http.Request) { } } - httpx.WriteJSON(w, http.StatusOK, chargingSessionResponse(session, live)) + httpx.WriteJSON(w, http.StatusOK, chargingSessionResponse(session, live, h.lookupTeslaBill(ctx, session))) } // chargingSessionResponse builds the JSON response map for a charging session, // including the live indicator. This preserves the original JSON field names // from the ChargingSession model while adding the extra "live" field. -func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool) map[string]interface{} { - return map[string]interface{}{ +func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool, bill *teslamodel.TeslaChargingHistoryEntry) map[string]interface{} { + resp := map[string]interface{}{ "id": s.ID, "vehicle_id": s.VehicleID, "started_at": s.StartedAt, @@ -183,6 +209,55 @@ func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool) map[st "cable_type": s.CableType, "live": live, } + if bill == nil { + return resp + } + if bill.UsageWh != nil { + resp["billed_energy_wh"] = *bill.UsageWh + } + if bill.TotalDue != nil { + resp["billed_cost_decimal"] = *bill.TotalDue + } + if bill.CurrencyCode != nil && *bill.CurrencyCode != "" { + resp["billed_currency"] = *bill.CurrencyCode + } + if bill.RateBase != nil { + resp["billed_rate_per_kwh"] = *bill.RateBase + } + resp["billed_source"] = "tesla_charging_history" + return resp +} + +// chargeEnergyBaselineLookback excludes the session-start telemetry batch from +// the cumulative energy baseline. See telemetry.chargeEnergyBaselineLookback. +const chargeEnergyBaselineLookback = time.Millisecond + +func chargeEnergyBaselineTime(start time.Time) time.Time { + if start.IsZero() { + return start + } + return start.Add(-chargeEnergyBaselineLookback) +} + +func (h *ChargingHandler) lookupTeslaBill(ctx context.Context, session *chargingmodel.ChargingSession) *teslamodel.TeslaChargingHistoryEntry { + if h == nil || h.teslaBills == nil || h.vehicles == nil || session == nil { + return nil + } + vehicle, err := h.vehicles.GetByID(ctx, session.VehicleID) + if err != nil || vehicle == nil || vehicle.VIN == "" { + if err != nil { + log.Warn().Err(err).Int64("vehicle_id", session.VehicleID). + Msg("charging: VIN lookup failed for Tesla bill overlay") + } + return nil + } + bill, err := h.teslaBills.FindBestMatch(ctx, vehicle.VIN, session.StartedAt) + if err != nil { + log.Warn().Err(err).Int64("session_id", session.ID). + Msg("charging: Tesla bill match failed") + return nil + } + return bill } // enrichLiveCharge computes live values for an in-progress charging session @@ -199,6 +274,17 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin } startSnap := stateToSignalMap(startState) + energyStartSnap := startSnap + if energyStartAt := chargeEnergyBaselineTime(session.StartedAt); !energyStartAt.Equal(session.StartedAt) { + energyStartState, energyErr := h.state.State(ctx, session.VehicleID, energyStartAt) + if energyErr != nil { + log.Warn().Err(energyErr).Int64("session_id", session.ID). + Msg("charging: energy baseline snapshot failed; using inclusive start") + } else { + energyStartSnap = stateToSignalMap(energyStartState) + } + } + currentSnap, err := h.currentSignals(ctx, session.VehicleID) if err != nil { return fmt.Errorf("current snapshot: %w", err) @@ -218,7 +304,7 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin } for _, field := range []string{"DCChargingEnergyIn", "ACChargingEnergyIn"} { - startEnergy, startOK := signalFloat(startSnap, field) + startEnergy, startOK := signalFloat(energyStartSnap, field) currentEnergy, currentOK := signalFloat(currentSnap, field) if startOK && currentOK && currentEnergy > startEnergy { delta := safeFloat(currentEnergy - startEnergy) diff --git a/internal/api/charging/handler_test.go b/internal/api/charging/handler_test.go index e2fe9d81f6..b8a0eda712 100644 --- a/internal/api/charging/handler_test.go +++ b/internal/api/charging/handler_test.go @@ -12,6 +12,8 @@ import ( "github.com/ev-dev-labs/teslasync/internal/api/apibulk" chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging" + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" "github.com/ev-dev-labs/teslasync/internal/signal" "github.com/go-chi/chi/v5" ) @@ -172,11 +174,12 @@ func TestChargingHandler_Latest_UsesNowSnapshot(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } - // enrichLiveCharge issues two State() calls: [0] start snapshot, [1] current. - if len(calls) < 2 { - t.Fatalf("State call count = %d, want at least 2 (start + current)", len(calls)) + // enrichLiveCharge issues three State() calls: [0] start snapshot, + // [1] exclusive energy baseline, [2] current (via LiveState fallback). + if len(calls) < 3 { + t.Fatalf("State call count = %d, want at least 3 (start + energy baseline + current)", len(calls)) } - cur := calls[1] + cur := calls[len(calls)-1] if cur.vehicleID != session.VehicleID { t.Fatalf("State[1].vehicleID = %d, want %d", cur.vehicleID, session.VehicleID) } @@ -191,7 +194,7 @@ func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) { session := inProgressChargingSession(11, 42, startTs) fake := &fakeStateReader{ stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) { - if at.Equal(startTs) { + if !at.After(startTs) { return signal.State{ "DCChargingEnergyIn": 100000.0, "BatteryLevel": 40.0, @@ -226,6 +229,112 @@ func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) { } } +func TestChargingHandler_LiveEnergyUsesExclusiveStartBaseline(t *testing.T) { + startTs := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC) + session := inProgressChargingSession(254, 42, startTs) + fake := &fakeStateReader{ + stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) { + if at.Equal(startTs) { + return signal.State{ + "DCChargingEnergyIn": 101870.0, + "BatteryLevel": 19.0, + }, nil + } + if at.Before(startTs) { + return signal.State{ + "DCChargingEnergyIn": 100000.0, + "BatteryLevel": 19.0, + }, nil + } + return signal.State{ + "DCChargingEnergyIn": 142620.0, + "DCChargingPower": 197000.0, + "BatteryLevel": 79.0, + }, nil + }, + } + charging := &fakeChargingByIDFetcher{session: session} + h := &ChargingHandler{state: fake, live: newTestLiveStateReader(fake), charging: charging} + + rec := httptest.NewRecorder() + h.Get(rec, newChargingRequest(t, "254", "")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var got map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + energy, ok := got["total_energy_added_wh"].(float64) + if !ok || energy != 42620 { + t.Fatalf("total_energy_added_wh = %v, want 42620 (exclusive baseline)", got["total_energy_added_wh"]) + } +} + +func TestChargingHandler_OverlaysTeslaSuperchargerBill(t *testing.T) { + startTs := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC) + endTs := startTs.Add(28 * time.Minute) + energy := 42620.0 + cost := 20.88 + session := completedChargingSession(254, 42, startTs, endTs) + session.TotalEnergyAddedWh = &energy + session.CostDecimal = &cost + + usage := 44490.6 + due := 21.80 + h := &ChargingHandler{ + charging: &fakeChargingByIDFetcher{session: session}, + vehicles: fakeVehicleVIN{vin: "5YJ3E1EA7KF000001"}, + teslaBills: fakeTeslaBills{entry: &teslamodel.TeslaChargingHistoryEntry{ + UsageWh: &usage, + TotalDue: &due, + CurrencyCode: strPtr("USD"), + RateBase: floatPtr(0.49), + }}, + } + + rec := httptest.NewRecorder() + h.Get(rec, newChargingRequest(t, "254", "")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var got map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got["billed_energy_wh"] != 44490.6 { + t.Fatalf("billed_energy_wh = %v, want 44490.6", got["billed_energy_wh"]) + } + if got["billed_cost_decimal"] != 21.80 { + t.Fatalf("billed_cost_decimal = %v, want 21.80", got["billed_cost_decimal"]) + } + if got["billed_source"] != "tesla_charging_history" { + t.Fatalf("billed_source = %v", got["billed_source"]) + } + if got["total_energy_added_wh"] != 42620.0 { + t.Fatalf("vehicle energy should stay 42620, got %v", got["total_energy_added_wh"]) + } +} + +type fakeVehicleVIN struct{ vin string } + +func (f fakeVehicleVIN) GetByID(_ context.Context, _ int64) (*vehiclemodel.Vehicle, error) { + return &vehiclemodel.Vehicle{VIN: f.vin}, nil +} + +type fakeTeslaBills struct { + entry *teslamodel.TeslaChargingHistoryEntry +} + +func (f fakeTeslaBills) FindBestMatch(_ context.Context, _ string, _ time.Time) (*teslamodel.TeslaChargingHistoryEntry, error) { + return f.entry, nil +} + +func strPtr(v string) *string { return &v } +func floatPtr(v float64) *float64 { return &v } + // TestChargingHandler_Telemetry_ChartMode locks in the chart-mode contract: // TelemetryReadings MUST call Timeline with an empty CollapseBy slice so // every change-feed emission becomes one row (forward-folded values appear diff --git a/internal/api/telemetry/telemetry_sessions_charge_tracking.go b/internal/api/telemetry/telemetry_sessions_charge_tracking.go index 783800713c..9a9839496f 100644 --- a/internal/api/telemetry/telemetry_sessions_charge_tracking.go +++ b/internal/api/telemetry/telemetry_sessions_charge_tracking.go @@ -159,6 +159,19 @@ func observeChargeEnergyCounter(active *streamingCharge, signals map[string]inte active.EnergyCounterLastWh = floatPtr(value) } +// chargeEnergyBaselineLookback excludes the session-start telemetry batch from +// the cumulative energy baseline. Fleet Telemetry often emits DetailedChargeState +// and DCChargingEnergyIn at the same timestamp after charging has already added +// energy; an inclusive State(StartedAt) therefore undercounts the session. +const chargeEnergyBaselineLookback = time.Millisecond + +func chargeEnergyBaselineTime(start time.Time) time.Time { + if start.IsZero() { + return start + } + return start.Add(-chargeEnergyBaselineLookback) +} + func snapshotChargeEnergyDelta( startSnap, endSnap map[string]interface{}, preferredField string, @@ -615,8 +628,20 @@ func (t *TelemetrySessionTracker) completeChargeLocked(ctx context.Context, vehi } // Energy added: difference in one consistent cumulative counter. + // Baseline is strictly before StartedAt so the first in-session + // energy sample is attributed to this session, not subtracted as + // the starting lifetime reading. + energyStartSnap := startSnap + if active.state != nil { + if energyStart, energyStartErr := active.state.State(ctx, vehicleID, chargeEnergyBaselineTime(active.StartTime)); energyStartErr != nil { + log.Warn().Err(energyStartErr).Int64("vehicle_id", vehicleID). + Msg("telemetry: state.State charge energy baseline snapshot failed") + } else { + energyStartSnap = stateToLegacyMap(energyStart) + } + } if energyDelta, kind, ok := snapshotChargeEnergyDelta( - startSnap, + energyStartSnap, endSnap, active.EnergyCounterField, ); ok { diff --git a/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go b/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go index e792fff713..3835407000 100644 --- a/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go +++ b/internal/api/telemetry/telemetry_sessions_charge_tracking_test.go @@ -536,3 +536,28 @@ func TestTrackCharging_StoppedKeepsSession(t *testing.T) { t.Fatal("charge session ended on Stopped") } } + +func TestChargeEnergyBaselineTimeExcludesStartBatch(t *testing.T) { + start := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC) + got := chargeEnergyBaselineTime(start) + if !got.Before(start) { + t.Fatalf("baseline %v is not before start %v", got, start) + } + if start.Sub(got) != chargeEnergyBaselineLookback { + t.Fatalf("lookback = %v, want %v", start.Sub(got), chargeEnergyBaselineLookback) + } + startSnap := map[string]interface{}{"DCChargingEnergyIn": 101870.0} + baselineSnap := map[string]interface{}{"DCChargingEnergyIn": 100000.0} + endSnap := map[string]interface{}{"DCChargingEnergyIn": 142620.0} + inclusive, _, _ := snapshotChargeEnergyDelta(startSnap, endSnap, "DCChargingEnergyIn") + exclusive, kind, ok := snapshotChargeEnergyDelta(baselineSnap, endSnap, "DCChargingEnergyIn") + if !ok || kind != signalcounter.ChangeAdvanced { + t.Fatalf("exclusive delta ok=%v kind=%v", ok, kind) + } + if inclusive != 40750 { + t.Fatalf("inclusive delta = %v, want 40750", inclusive) + } + if exclusive != 42620 { + t.Fatalf("exclusive delta = %v, want 42620", exclusive) + } +} diff --git a/internal/database/tesla/charging_history_repo.go b/internal/database/tesla/charging_history_repo.go index 5bc15a2e16..9cf9a76d3b 100644 --- a/internal/database/tesla/charging_history_repo.go +++ b/internal/database/tesla/charging_history_repo.go @@ -64,14 +64,13 @@ func (r *TeslaChargingHistoryRepo) GetAll(ctx context.Context, vin string, limit return results, rows.Err() } -// GetBySessionID returns a single charging history entry by Tesla session ID. -func (r *TeslaChargingHistoryRepo) GetBySessionID(ctx context.Context, sessionID int64) (*teslamodel.TeslaChargingHistoryEntry, error) { - query := `SELECT id, session_id, vin, site_location_name, charge_start_datetime, charge_stop_datetime, +const teslaChargingHistoryColumns = `id, session_id, vin, site_location_name, charge_start_datetime, charge_stop_datetime, country, state, county, postal_code, billing_type, fee_type, currency_code, pricing_type, - rate_base, usage_wh, total_due, has_invoice, invoice_content_id, fetched_at, created_at - FROM tesla_charging_history WHERE session_id = $1` + rate_base, usage_wh, total_due, has_invoice, invoice_content_id, fetched_at, created_at` + +func scanTeslaChargingHistory(row interface{ Scan(dest ...any) error }) (*teslamodel.TeslaChargingHistoryEntry, error) { e := &teslamodel.TeslaChargingHistoryEntry{} - err := r.pool.QueryRow(ctx, query, sessionID).Scan( + err := row.Scan( &e.ID, &e.SessionID, &e.VIN, &e.SiteLocationName, &e.ChargeStartDatetime, &e.ChargeStopDatetime, &e.Country, &e.State, &e.County, &e.PostalCode, @@ -80,6 +79,16 @@ func (r *TeslaChargingHistoryRepo) GetBySessionID(ctx context.Context, sessionID &e.HasInvoice, &e.InvoiceContentID, &e.FetchedAt, &e.CreatedAt, ) + if err != nil { + return nil, err + } + return e, nil +} + +// GetBySessionID returns a single charging history entry by Tesla session ID. +func (r *TeslaChargingHistoryRepo) GetBySessionID(ctx context.Context, sessionID int64) (*teslamodel.TeslaChargingHistoryEntry, error) { + query := `SELECT ` + teslaChargingHistoryColumns + ` FROM tesla_charging_history WHERE session_id = $1` + e, err := scanTeslaChargingHistory(r.pool.QueryRow(ctx, query, sessionID)) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -89,6 +98,35 @@ func (r *TeslaChargingHistoryRepo) GetBySessionID(ctx context.Context, sessionID return e, nil } +// teslaBillMatchWindow is how far a Supercharger invoice start may sit from a +// measured charging_sessions.started_at and still be treated as the same event. +const teslaBillMatchWindow = 2 * time.Hour + +// FindBestMatch returns the Tesla charging-history invoice whose start time is +// closest to startedAt for vin, within teslaBillMatchWindow. No row maps to +// nil, nil so callers can overlay billed kWh/cost without failing the session. +func (r *TeslaChargingHistoryRepo) FindBestMatch(ctx context.Context, vin string, startedAt time.Time) (*teslamodel.TeslaChargingHistoryEntry, error) { + if vin == "" || startedAt.IsZero() { + return nil, nil + } + query := `SELECT ` + teslaChargingHistoryColumns + ` +FROM tesla_charging_history +WHERE vin = $1 + AND charge_start_datetime BETWEEN $2 AND $3 +ORDER BY ABS(EXTRACT(EPOCH FROM (charge_start_datetime - $4))) ASC, session_id DESC +LIMIT 1` + from := startedAt.Add(-teslaBillMatchWindow) + to := startedAt.Add(teslaBillMatchWindow) + e, err := scanTeslaChargingHistory(r.pool.QueryRow(ctx, query, vin, from, to, startedAt)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find tesla charging history match: %w", err) + } + return e, nil +} + // GetSummary returns aggregated stats for Tesla charging history, optionally filtered by VIN. func (r *TeslaChargingHistoryRepo) GetSummary(ctx context.Context, vin string) (*teslamodel.TeslaChargingHistorySummary, error) { query := `SELECT COUNT(*), SUM(usage_wh), SUM(total_due), diff --git a/internal/database/tesla/charging_history_repo_test.go b/internal/database/tesla/charging_history_repo_test.go index 5e44628893..fa4a9fd933 100644 --- a/internal/database/tesla/charging_history_repo_test.go +++ b/internal/database/tesla/charging_history_repo_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" @@ -187,6 +188,55 @@ func TestChargingHistoryRepo_GetBySessionID(t *testing.T) { } } +func TestChargingHistoryRepo_FindBestMatch(t *testing.T) { + t.Parallel() + e := sampleHistoryEntry() + started := e.ChargeStartDatetime + + t.Run("found", func(t *testing.T) { + t.Parallel() + pool := &fakePool{queryRowQueue: []pgx.Row{fakeRow{vals: chargingHistoryRow(e)}}} + repo := &TeslaChargingHistoryRepo{pool: pool} + got, err := repo.FindBestMatch(context.Background(), e.VIN, started) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got == nil || got.SessionID != e.SessionID { + t.Fatalf("unexpected row: %+v", got) + } + if !strings.Contains(pool.queryRowCalls[0].sql, "charge_start_datetime BETWEEN") { + t.Errorf("SQL missing match window: %s", pool.queryRowCalls[0].sql) + } + assertArgsEqual(t, pool.queryRowCalls[0].args, []any{e.VIN, started.Add(-2 * time.Hour), started.Add(2 * time.Hour), started}) + }) + + t.Run("not found maps to nil,nil", func(t *testing.T) { + t.Parallel() + pool := &fakePool{queryRowQueue: []pgx.Row{noRow()}} + repo := &TeslaChargingHistoryRepo{pool: pool} + got, err := repo.FindBestMatch(context.Background(), e.VIN, started) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != nil { + t.Fatalf("want nil, got %+v", got) + } + }) + + t.Run("empty vin short-circuits", func(t *testing.T) { + t.Parallel() + pool := &fakePool{} + repo := &TeslaChargingHistoryRepo{pool: pool} + got, err := repo.FindBestMatch(context.Background(), "", started) + if err != nil || got != nil { + t.Fatalf("got (%v, %v), want nil,nil", got, err) + } + if len(pool.queryRowCalls) != 0 { + t.Fatalf("expected no query, got %d", len(pool.queryRowCalls)) + } + }) +} + func TestChargingHistoryRepo_GetSummary(t *testing.T) { t.Parallel() sum := &teslamodel.TeslaChargingHistorySummary{ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 79a52952d8..50f2fdbc0a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -183,6 +183,13 @@ export interface ChargingSession { duration_min: number cost?: number | null ended_status?: string | null + /** Supercharger invoice energy in watt-hours when Tesla billing history matches. */ + billed_energy_wh?: number | null + /** Supercharger invoice total when Tesla billing history matches. */ + billed_cost_decimal?: number | null + billed_currency?: string | null + billed_rate_per_kwh?: number | null + billed_source?: string | null } export interface DriveTelemetryReading { diff --git a/web/src/features/charging/pages/ChargingDetailPage.test.tsx b/web/src/features/charging/pages/ChargingDetailPage.test.tsx index c4a27657f5..09fde9566e 100644 --- a/web/src/features/charging/pages/ChargingDetailPage.test.tsx +++ b/web/src/features/charging/pages/ChargingDetailPage.test.tsx @@ -430,6 +430,31 @@ describe('ChargingDetailPage — populated DC session', () => { expect(cardValue(kpi, 'Miles Added')).toBe('—'); }); + it('prefers Tesla billed energy and cost on the KPI tiles without overwriting vehicle energy', () => { + mockSession.mockReturnValue( + makeQuery({ + data: makeSession({ + total_energy_added_wh: 42_620, + cost_decimal: 20.88, + billed_energy_wh: 44_490.6, + billed_cost_decimal: 21.8, + billed_currency: 'USD', + billed_rate_per_kwh: 0.49, + billed_source: 'tesla_charging_history', + }), + }), + ); + renderPage(); + const kpi = kpiRegion(); + + expect(cardValue(kpi, 'Energy')).toBe('44.49 kWh'); + expect(cardValue(kpi, 'Total Cost')).toBe('$21.80'); + expect(cardValue(kpi, 'Per kWh')).toBe('$0.49/kWh'); + expect(within(kpi).getByText('Vehicle measured 42.62 kWh')).toBeInTheDocument(); + // Charge-summary restates vehicle energy, not the Supercharger bill. + expect(screen.getByText('42.6 kWh')).toBeInTheDocument(); + }); + it('renders the five live gauges with SI-converted values and the DC 250 kW ceiling', () => { renderPage(); diff --git a/web/src/features/charging/pages/ChargingDetailPage.tsx b/web/src/features/charging/pages/ChargingDetailPage.tsx index bee8a96c42..e6e3c5a7e8 100644 --- a/web/src/features/charging/pages/ChargingDetailPage.tsx +++ b/web/src/features/charging/pages/ChargingDetailPage.tsx @@ -53,12 +53,6 @@ function isDC(session: ChargingSession): boolean { return ft !== '' && ft !== '' && ft !== 'unknown'; } -function kwhPerHour(session: ChargingSession): number | null { - const durationMin = durationMinutes(session.started_at, session.ended_at); - if (durationMin <= 0) return null; - return (session.total_energy_added_wh / 1000 / durationMin) * 60; -} - /** Synthesize a plausible charge curve when telemetry is absent. */ function synthesizeCurve(session: ChargingSession): { soc: number; power: number }[] { const startSoc = session.start_soc_pct ?? 0; @@ -291,19 +285,24 @@ export default function ChargingDetailPage() { /* ─── derived scalars (session is now guaranteed) ─── */ - const avgRate = kwhPerHour(session); const durationMin = durationMinutes(session.started_at, session.ended_at); const addedDistanceM = distanceAddedM(session); + const billedEnergyWh = session.billed_energy_wh ?? null; + const vehicleEnergyWh = session.total_energy_added_wh ?? 0; + const displayEnergyWh = billedEnergyWh != null && billedEnergyWh > 0 ? billedEnergyWh : vehicleEnergyWh; + const avgRate = durationMin > 0 ? (displayEnergyWh / 1000 / durationMin) * 60 : null; + const billedCost = session.billed_cost_decimal ?? null; + const displayCost = billedCost ?? session.cost_decimal ?? null; const perKwhRate = - session.cost_decimal != null && session.total_energy_added_wh > 0 - ? session.cost_decimal / (session.total_energy_added_wh / 1000) - : null; + displayCost != null && displayEnergyWh > 0 + ? displayCost / (displayEnergyWh / 1000) + : session.billed_rate_per_kwh ?? null; const costValue = - session.cost_decimal != null - ? formatCurrency(session.cost_decimal, 2) - : session.total_energy_added_wh > 0 - ? formatEnergyCost(session.total_energy_added_wh / 1000) + displayCost != null + ? formatCurrency(displayCost, 2) + : displayEnergyWh > 0 + ? formatEnergyCost(displayEnergyWh / 1000) : '—'; const chargerLabel = session.charger_type ?? (dc ? 'DC' : 'AC'); @@ -320,8 +319,8 @@ export default function ChargingDetailPage() { key: 'energy', color: '#00f0ff', glow: 'cyan' as const, - value: convertEnergyFromSI(session.total_energy_added_wh ?? 0, unitPrefs.energy), - max: Math.max(convertEnergyFromSI(session.total_energy_added_wh ?? 1, unitPrefs.energy), 80), + value: convertEnergyFromSI(displayEnergyWh ?? 0, unitPrefs.energy), + max: Math.max(convertEnergyFromSI(displayEnergyWh || 1, unitPrefs.energy), 80), label: t('charging.detail.energyAdded', 'Energy Added'), unit: unitPrefs.energy, }, @@ -439,9 +438,26 @@ export default function ChargingDetailPage() { >