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:
@@ -38,6 +38,15 @@ export async function logoutOwner(): Promise<void> {
|
|||||||
await fetch('/api/auth/owner/logout', { method: 'POST' });
|
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 {
|
export interface TeslaState {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
asleep?: boolean;
|
asleep?: boolean;
|
||||||
@@ -58,6 +67,7 @@ export interface TeslaState {
|
|||||||
vin?: string | null;
|
vin?: string | null;
|
||||||
vehicleName?: string | null;
|
vehicleName?: string | null;
|
||||||
softwareVersion?: string | null;
|
softwareVersion?: string | null;
|
||||||
|
activeRoute?: TeslaActiveRoute | null;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ export function getMockState(): TeslaState | null {
|
|||||||
lng: -0.2024,
|
lng: -0.2024,
|
||||||
speedKmh: 0,
|
speedKmh: 0,
|
||||||
shiftState: 'P' as const,
|
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,
|
lng: BASE_LNG + 0.008 * driveTickCount,
|
||||||
speedKmh: 112,
|
speedKmh: 112,
|
||||||
shiftState: 'D' as const,
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
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 { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock';
|
||||||
import { detectInCar } from '../lib/incar';
|
import { detectInCar } from '../lib/incar';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
@@ -1396,11 +1396,10 @@ function TopBar({
|
|||||||
onDisconnectTesla: () => void;
|
onDisconnectTesla: () => void;
|
||||||
inCar: boolean;
|
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;
|
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 hideGpxChip = inCar;
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const handleLocate = async () => {
|
const handleLocate = async () => {
|
||||||
@@ -1432,23 +1431,22 @@ function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Origin → Destination */}
|
{/* 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
|
<div
|
||||||
className="flex items-center rounded-[10px] overflow-hidden"
|
className="flex items-center rounded-[10px] overflow-hidden"
|
||||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
|
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="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)' }} />
|
<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>
|
|
||||||
) : (
|
|
||||||
<input
|
<input
|
||||||
value={origin}
|
value={origin}
|
||||||
onChange={(e) => onOriginChange(e.target.value)}
|
onChange={(e) => onOriginChange(e.target.value)}
|
||||||
@@ -1458,7 +1456,6 @@ function TopBar({
|
|||||||
className="bg-transparent border-none outline-none text-[13px] w-full"
|
className="bg-transparent border-none outline-none text-[13px] w-full"
|
||||||
style={{ color: 'var(--gd-text)' }}
|
style={{ color: 'var(--gd-text)' }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLocate}
|
onClick={handleLocate}
|
||||||
disabled={locating}
|
disabled={locating}
|
||||||
@@ -1487,6 +1484,7 @@ function TopBar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<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 }: {
|
function ChargingWidget({ kw, minutesToTarget, battery }: {
|
||||||
kw: number | null;
|
kw: number | null;
|
||||||
minutesToTarget: number | null;
|
minutesToTarget: number | null;
|
||||||
@@ -1741,6 +1790,18 @@ export default function TeslaTripPlanner() {
|
|||||||
});
|
});
|
||||||
}, [teslaInCar, tesla.state?.lat, tesla.state?.lng]);
|
}, [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
|
// Surface a toast once after the OAuth round trip lands us back at /?tesla_connected=1
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|||||||
@@ -210,6 +210,21 @@ router.get('/api/tesla/state', requireOwner, async (req, res) => {
|
|||||||
vin: tokens.vin ? `…${tokens.vin.slice(-4)}` : null,
|
vin: tokens.vin ? `…${tokens.vin.slice(-4)}` : null,
|
||||||
vehicleName: vs.vehicle_name ?? null,
|
vehicleName: vs.vehicle_name ?? null,
|
||||||
softwareVersion: vs.car_version ?? 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(),
|
fetchedAt: Date.now(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user