Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
fdc3f30
feat(charging): overlay Tesla bills, fix energy baseline
atulmgupta Sep 7, 2026
6337833
Fix FSD trip meter glitch attribution
atulmgupta Sep 7, 2026
d0148d2
Add Grok dynamics briefing
atulmgupta Sep 7, 2026
3819d38
Add telemetry honesty insights
atulmgupta Sep 7, 2026
21ef53b
feat(driving): scope dynamics history by trip
atulmgupta Sep 8, 2026
1d67c53
fix(charging): correct history attribution
atulmgupta Sep 8, 2026
2b7a9d3
Fix sparse FSD drive detail attribution
atulmgupta Sep 8, 2026
da1e97d
Scope FSD insights to drive detail
atulmgupta Sep 10, 2026
d813d23
feat: add charging autopilot, OCPP, TCO ledger, and session insights
atulmgupta Sep 11, 2026
d30c301
Merge branch 'main' into muse-spark
atulmgupta Sep 11, 2026
8969c19
feat(charging): add charge autopilot
atulmgupta Sep 11, 2026
9095819
feat: expand charging and automation insights
atulmgupta Sep 11, 2026
49a0b3d
Expand automation and alert templates
atulmgupta Sep 11, 2026
e69c239
feat: storm guardian severe-weather auto-prep
atulmgupta Sep 11, 2026
cacdf6c
feat: cabin comfort autopilot with calendar-aware preconditioning
atulmgupta Sep 11, 2026
728760e
feat: warranty claim autopilot drafts service tickets
atulmgupta Sep 11, 2026
3a281be
feat: ghost-driver alerting for unknown-driver drives
atulmgupta Sep 11, 2026
7a581f3
feat: supercharger wait-time oracle from fleet history
atulmgupta Sep 11, 2026
3f050b0
fix(fsd): accept quantized FSD mileage ticks
atulmgupta Sep 12, 2026
583e034
fix: align wait-oracle, ghost, claim, comfort, storm with conventions
atulmgupta Sep 12, 2026
9af0f7e
feat(journey): trip sessions with status machine and versioned plans
atulmgupta Sep 12, 2026
6e5f05e
docs: redesign site and add feature catalogue
atulmgupta Sep 13, 2026
0db770f
fix(journey): clamp limits and localize status
atulmgupta Sep 13, 2026
2fa2af4
feat(i18n): add journey status labels
atulmgupta Sep 13, 2026
2bd335d
test(trips): align journey panel i18n
atulmgupta Sep 13, 2026
f3e59b7
merge origin/main into muse-spark
atulmgupta Sep 13, 2026
55386fe
refactor: tighten package boundaries
atulmgupta Sep 13, 2026
6dc2572
chore: document packages and update grpc
atulmgupta Sep 13, 2026
62e3abb
fix(ci): empty-state audit exemptions and Gitleaks policy fixture mount
atulmgupta Sep 13, 2026
4bc6c48
fix(ci): arch ratchet, chart-legend, CodeQL node, visual snapshots
atulmgupta Sep 13, 2026
34989da
fix(ci): restore CodeQL SHA pin and run SI gate on ubuntu-latest
atulmgupta Sep 13, 2026
a3adeb6
fix(ci): run SI canonical gate on push so the required workflow has jobs
atulmgupta Sep 13, 2026
e453ff6
fix(web): classify new mutation hooks for live-mode audit
atulmgupta Sep 13, 2026
17dd48c
fix(web): waive FSD observatory pagination in virtualization audit
atulmgupta Sep 13, 2026
88080af
fix(web): unblock frontend vitest QueryClient, breadcrumbs, and panel…
atulmgupta Sep 13, 2026
3796b0c
fix(web): show warranty claim draft when query data is present
atulmgupta Sep 13, 2026
00cc79c
fix(web): restore i18n provider and annotation hook tests under globa…
atulmgupta Sep 13, 2026
1c9dcd0
fix(web): unblock remaining frontend vitest failures
atulmgupta Sep 13, 2026
2c2ecb7
fix(ci): stop si-canonical-gate 0s push failures
atulmgupta Sep 13, 2026
6c6c050
fix(ci): run si-canonical-gate on push with a valid runner
atulmgupta Sep 13, 2026
cd43848
fix(ci): always run si-canonical-gate on push and PR
atulmgupta Sep 13, 2026
9be0a3b
fix(ci): move SI canonical gate Python out of invalid YAML
atulmgupta Sep 13, 2026
2f47434
fix(ci): repair span-budget, proto-gen, and load-test gates
atulmgupta Sep 13, 2026
3a147b8
fix(ci): run k6 on host network so load-test reaches the API
atulmgupta Sep 14, 2026
4b94ebc
fix(ci): treat 4xx as smoke-ok and write k6 summary in script cwd
atulmgupta Sep 14, 2026
272f432
fix(ci): persist k6 summary.json with host uid on bind mount
atulmgupta Sep 14, 2026
c8ac9b1
Merge branch 'main' into muse-spark
atulmgupta Sep 14, 2026
727d8ed
feat(journey): stop scoring on wait, price, health, corridor
atulmgupta Sep 14, 2026
b481cd6
feat(journey): departure advisor and ready-to-roll checklist
atulmgupta Sep 14, 2026
5e159fa
feat(journey): live trip session with trail checkpoints and glanceabl…
atulmgupta Sep 14, 2026
d9cec25
feat(journey): replan engine with deviation verdict and one-click res…
atulmgupta Sep 14, 2026
b4e1a21
feat(journey): arrival prep with pace ETA and destination charge advice
atulmgupta Sep 14, 2026
79c6e96
feat(journey): trip report card debrief
atulmgupta Sep 14, 2026
eb134ed
feat(journey): leave-now nudge from calm slots and blockers
atulmgupta Sep 14, 2026
e5d5337
feat(journey): route learning adjusts ETA and debrief
atulmgupta Sep 14, 2026
a22e294
feat(journey): offline check-in outbox with reconnect replay
atulmgupta Sep 14, 2026
ddb08bd
refactor(trips): remove obsolete journey strings
atulmgupta Sep 14, 2026
a317612
Merge branch 'main' into muse-spark
atulmgupta Sep 14, 2026
eb55402
fix(journey): SI identifiers and ops migration manifest
atulmgupta Sep 14, 2026
dc3a07a
fix(web): exempt journey EmptyStates from CTA audit
atulmgupta Sep 14, 2026
732ad19
fix(web): give journey empty states real CTAs
atulmgupta Sep 14, 2026
8f4ce57
fix(web): narrow checklist run before rendering items
atulmgupta Sep 14, 2026
966cff1
fix(web): classify journey mutations for live-mode audit
atulmgupta Sep 14, 2026
36f8072
fix(web): align DeparturePanel tests with render and hook args
atulmgupta Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions internal/api/journey/arrival.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading