feat(tesla): collapse OD strip when connected, sync with in-car nav destination

- Server: include active_route_destination (lat/lng/eta) from drive_state.
- When Tesla is connected, the Origin → Destination strip collapses to a
  single ConnectedTripStrip: "From car · Where to?". The origin is implicit
  (the car's GPS), the manual From input + crosshair button disappear.
- If Tesla nav already has a destination, it auto-fills as the trip
  destination; if the user has typed something else, an inline "Use Tesla
  nav" button offers a one-tap swap.
- Mocks: driving + charging scenarios include an activeRoute so the flow
  is testable end-to-end via ?mockTesla=driving / ?mockTesla=charging.
This commit is contained in:
2026-05-31 22:58:42 +01:00
parent 7265103573
commit 381eb18cd3
4 changed files with 152 additions and 50 deletions
+111 -50
View File
@@ -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({
</div>
</div>
{/* Origin → Destination */}
<div
className="flex items-center rounded-[10px] overflow-hidden"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
>
<div className="px-3.5 flex items-center gap-2 h-full flex-1" style={{ borderRight: '1px solid var(--gd-border)' }}>
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-text-2)' }} />
{showFromAsPill ? (
<div
className="text-[12px] w-full flex items-center gap-1.5"
style={{ color: 'var(--gd-text)' }}
title="Auto-detected from your Tesla's GPS"
>
<Car className="w-3 h-3" style={{ color: 'var(--gd-red)' }} />
<span className="truncate">{origin || 'Locating your car…'}</span>
</div>
) : (
{/* Origin → Destination — collapsed to a single destination-focused strip
when Tesla is connected, because the origin is just "where the car is". */}
{teslaStatus?.connected ? (
<ConnectedTripStrip
activeRoute={teslaState?.activeRoute ?? null}
destination={destination}
onDestinationChange={onDestinationChange}
onCommit={onODCommit}
/>
) : (
<div
className="flex items-center rounded-[10px] overflow-hidden"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
>
<div className="px-3.5 flex items-center gap-2 h-full flex-1" style={{ borderRight: '1px solid var(--gd-border)' }}>
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-text-2)' }} />
<input
value={origin}
onChange={(e) => 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)' }}
/>
)}
<button
onClick={handleLocate}
disabled={locating}
title="Use my current location"
className="grid place-items-center rounded-md flex-shrink-0 disabled:opacity-40"
style={{
width: 26, height: 26,
background: 'var(--gd-panel-2)',
border: '1px solid var(--gd-border)',
color: 'var(--gd-text-2)',
}}
>
<Crosshair className={`w-3.5 h-3.5 ${locating ? 'animate-pulse' : ''}`} />
</button>
<button
onClick={handleLocate}
disabled={locating}
title="Use my current location"
className="grid place-items-center rounded-md flex-shrink-0 disabled:opacity-40"
style={{
width: 26, height: 26,
background: 'var(--gd-panel-2)',
border: '1px solid var(--gd-border)',
color: 'var(--gd-text-2)',
}}
>
<Crosshair className={`w-3.5 h-3.5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
<div className="px-3.5 flex items-center gap-2 h-full flex-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
<input
value={destination}
onChange={(e) => 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)' }}
/>
</div>
</div>
<div className="px-3.5 flex items-center gap-2 h-full flex-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
<input
value={destination}
onChange={(e) => 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)' }}
/>
</div>
</div>
)}
<div className="flex-1" />
@@ -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 (
<div
className="flex items-center rounded-[10px] overflow-hidden"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
>
<div className="px-3 flex items-center gap-2 h-full" style={{ borderRight: '1px solid var(--gd-border)' }}>
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
<div className="text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>From car</div>
</div>
<div className="px-3 flex items-center gap-2 h-full flex-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
<input
value={destination}
onChange={(e) => 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 && (
<button
onClick={() => { onDestinationChange(navDest); onCommit(); }}
title="Use the destination set in your Tesla nav"
className="h-7 px-2 text-[10.5px] rounded-md inline-flex items-center gap-1 whitespace-nowrap"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)', border: '1px solid var(--gd-red-line)' }}
>
<Navigation className="w-2.5 h-2.5" /> Use Tesla nav
</button>
)}
</div>
</div>
);
}
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);