diff --git a/client/src/lib/tesla.ts b/client/src/lib/tesla.ts index c92d1c3..d3bd3e7 100644 --- a/client/src/lib/tesla.ts +++ b/client/src/lib/tesla.ts @@ -38,6 +38,15 @@ export async function logoutOwner(): Promise { await fetch('/api/auth/owner/logout', { method: 'POST' }); } +export interface TeslaActiveRoute { + destination: string; + lat: number | null; + lng: number | null; + kmToArrival: number | null; + minutesToArrival: number | null; + trafficMinutesDelay: number; +} + export interface TeslaState { connected: boolean; asleep?: boolean; @@ -58,6 +67,7 @@ export interface TeslaState { vin?: string | null; vehicleName?: string | null; softwareVersion?: string | null; + activeRoute?: TeslaActiveRoute | null; fetchedAt: number; } diff --git a/client/src/lib/teslaMock.ts b/client/src/lib/teslaMock.ts index 7de9675..f8679c1 100644 --- a/client/src/lib/teslaMock.ts +++ b/client/src/lib/teslaMock.ts @@ -128,6 +128,14 @@ export function getMockState(): TeslaState | null { lng: -0.2024, speedKmh: 0, shiftState: 'P' as const, + activeRoute: { + destination: 'Eurotunnel Folkestone', + lat: 51.0901, + lng: 1.1340, + kmToArrival: 178, + minutesToArrival: 132, + trafficMinutesDelay: 8, + }, }; } @@ -147,6 +155,14 @@ export function getMockState(): TeslaState | null { lng: BASE_LNG + 0.008 * driveTickCount, speedKmh: 112, shiftState: 'D' as const, + activeRoute: { + destination: 'Cambridge Services M11', + lat: 52.1389, + lng: 0.1832, + kmToArrival: Math.max(0, 78 - driveTickCount * 0.6), + minutesToArrival: Math.max(0, Math.round((78 - driveTickCount * 0.6) / 110 * 60)), + trafficMinutesDelay: 0, + }, }; } diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index 3fe32cc..ad97e8a 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { toast } from 'sonner'; import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; -import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner } from '../lib/tesla'; +import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner, type TeslaActiveRoute } from '../lib/tesla'; import { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock'; import { detectInCar } from '../lib/incar'; import L from 'leaflet'; @@ -1396,11 +1396,10 @@ function TopBar({ onDisconnectTesla: () => void; inCar: boolean; }) { - // Tesla connected → we know the car, so the manual vehicle picker is dead weight. + // Tesla connected → we know the car (and where it is), so the vehicle picker + // and the manual From input are both dead weight. The OD strip collapses to + // a destination-focused planner (see ConnectedTripStrip). const hideVehicleChip = !!teslaStatus?.connected; - // In-car AND connected → origin is the car's actual GPS, so collapse the - // From input to a static pill (typing on a touchscreen sucks). - const showFromAsPill = inCar && !!teslaStatus?.connected; const hideGpxChip = inCar; const [locating, setLocating] = React.useState(false); const handleLocate = async () => { @@ -1432,23 +1431,22 @@ function TopBar({ - {/* Origin → Destination */} -
-
-
- {showFromAsPill ? ( -
- - {origin || 'Locating your car…'} -
- ) : ( + {/* Origin → Destination — collapsed to a single destination-focused strip + when Tesla is connected, because the origin is just "where the car is". */} + {teslaStatus?.connected ? ( + + ) : ( +
+
+
onOriginChange(e.target.value)} @@ -1458,35 +1456,35 @@ function TopBar({ className="bg-transparent border-none outline-none text-[13px] w-full" style={{ color: 'var(--gd-text)' }} /> - )} - + +
+
+
+ onDestinationChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }} + onBlur={onODCommit} + placeholder="To" + className="bg-transparent border-none outline-none text-[13px] w-full" + style={{ color: 'var(--gd-text)' }} + /> +
-
-
- onDestinationChange(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }} - onBlur={onODCommit} - placeholder="To" - className="bg-transparent border-none outline-none text-[13px] w-full" - style={{ color: 'var(--gd-text)' }} - /> -
-
+ )}
@@ -1627,6 +1625,57 @@ function TopBar({ ); } +// Compact destination strip used when Tesla is connected. Origin is implicit +// (the car's GPS), so we only need to know where the user is going. +function ConnectedTripStrip({ + activeRoute, destination, onDestinationChange, onCommit, +}: { + activeRoute: TeslaActiveRoute | null; + destination: string; + onDestinationChange: (v: string) => void; + onCommit: () => void; +}) { + // If Tesla nav already has a destination AND it doesn't match what the + // planner's currently using, surface it as a one-tap apply suggestion. + const navDest = activeRoute?.destination; + const navMatches = !!navDest && destination.trim().toLowerCase() === navDest.toLowerCase(); + const showSuggestion = !!navDest && !navMatches; + + return ( +
+
+ +
From car
+
+
+
+ onDestinationChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onCommit(); } }} + onBlur={onCommit} + placeholder={navDest ? `Going to ${navDest} (or type)` : 'Where to?'} + className="bg-transparent border-none outline-none text-[13px] w-full" + style={{ color: 'var(--gd-text)' }} + /> + {showSuggestion && ( + + )} +
+
+ ); +} + function ChargingWidget({ kw, minutesToTarget, battery }: { kw: number | null; minutesToTarget: number | null; @@ -1741,6 +1790,18 @@ export default function TeslaTripPlanner() { }); }, [teslaInCar, tesla.state?.lat, tesla.state?.lng]); + // When Tesla is connected and has an active in-car nav destination, and the + // user hasn't typed their own destination yet, adopt the Tesla nav target. + const autoDestRef = React.useRef(false); + React.useEffect(() => { + if (!teslaConnected || autoDestRef.current) return; + const nav = tesla.state?.activeRoute?.destination; + if (!nav) return; + if (destination.trim()) return; // user already chose one + autoDestRef.current = true; + setDestination(nav); + }, [teslaConnected, tesla.state?.activeRoute?.destination]); + // Surface a toast once after the OAuth round trip lands us back at /?tesla_connected=1 React.useEffect(() => { const params = new URLSearchParams(window.location.search); diff --git a/server/routes/tesla.ts b/server/routes/tesla.ts index 04e060c..55e5276 100644 --- a/server/routes/tesla.ts +++ b/server/routes/tesla.ts @@ -210,6 +210,21 @@ router.get('/api/tesla/state', requireOwner, async (req, res) => { vin: tokens.vin ? `…${tokens.vin.slice(-4)}` : null, vehicleName: vs.vehicle_name ?? null, softwareVersion: vs.car_version ?? null, + // Active in-car nav destination (set in Tesla nav by the driver, or by + // our own send-to-nav). Useful as a default trip destination. + activeRoute: (typeof ds.active_route_destination === 'string' && ds.active_route_destination) + ? { + destination: ds.active_route_destination, + lat: typeof ds.active_route_latitude === 'number' ? ds.active_route_latitude : null, + lng: typeof ds.active_route_longitude === 'number' ? ds.active_route_longitude : null, + kmToArrival: typeof ds.active_route_miles_to_arrival === 'number' + ? Math.round(ds.active_route_miles_to_arrival * 1.60934) : null, + minutesToArrival: typeof ds.active_route_minutes_to_arrival === 'number' + ? Math.round(ds.active_route_minutes_to_arrival) : null, + trafficMinutesDelay: typeof ds.active_route_traffic_minutes_delay === 'number' + ? Math.round(ds.active_route_traffic_minutes_delay) : 0, + } + : null, fetchedAt: Date.now(), }); } catch (err) {