feat(tesla): client-side mock + driving-mode card

- teslaMock: ?mockTesla=parked|driving|charging|asleep returns canned
  state, persisted via localStorage. Bypasses Fleet API entirely;
  short-circuited off in import.meta.env.PROD so prod can't enable it.
  Driving scenario animates battery + position so the UI feels alive.
- useTesla polls every 3s when mocking (vs 60s real) so animation works.
- MOCK badge in the top bar cycles scenarios in-place.

- DrivingMode: full-screen takeover when shiftState != P (or ?drivingMode=1).
  Battery + range chip, big speed readout, hero next-stop card with name,
  distance, ETA, "send to Tesla nav" button, charging strip at the bottom
  when plugged in, exit button for debug. Picks nearest planned stop to
  the car's current GPS as the "next stop".
This commit is contained in:
2026-05-31 22:41:24 +01:00
parent f793b526aa
commit 5b97f5b873
3 changed files with 397 additions and 1 deletions
+224
View File
@@ -2,6 +2,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 { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock';
import { detectInCar } from '../lib/incar';
import L from 'leaflet';
import {
@@ -1597,6 +1598,38 @@ function TopBar({
{grokStatus.label}
</div>
)}
<MockTeslaIndicator />
</div>
);
}
function MockTeslaIndicator() {
const [scenario, setScenarioState] = React.useState(getMockScenario());
if (!scenario) return null;
const cycle = () => {
const next = scenario === 'parked' ? 'driving'
: scenario === 'driving' ? 'charging'
: scenario === 'charging' ? 'asleep'
: scenario === 'asleep' ? 'parked'
: 'parked';
setMockScenario(next);
resetMockDrive();
setScenarioState(next);
// Hard reload so all hooks pick up the new scenario cleanly.
window.location.reload();
};
const disable = () => {
setMockScenario(null);
window.location.reload();
};
return (
<div
className="text-[10px] px-2 py-1 rounded inline-flex items-center gap-1.5"
style={{ background: 'rgba(251,191,36,0.15)', color: 'var(--gd-amber)', border: '1px solid rgba(251,191,36,0.45)' }}
>
<span className="font-semibold tracking-wider uppercase">MOCK</span>
<button onClick={cycle} className="underline" title="Cycle scenario">{scenario}</button>
<button onClick={disable} className="opacity-70 hover:opacity-100" title="Disable mock">×</button>
</div>
);
}
@@ -2172,8 +2205,51 @@ export default function TeslaTripPlanner() {
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
const dateLabels = ['Today', 'Tomorrow'];
// Driving-mode trigger: car is moving (or in a non-Park gear). Also enabled
// via ?drivingMode=1 for development.
const forceDrivingMode = React.useMemo(() => {
if (typeof window === 'undefined') return false;
return new URLSearchParams(window.location.search).get('drivingMode') === '1';
}, []);
const isDriving = forceDrivingMode || (
!!tesla.state?.shiftState && tesla.state.shiftState !== 'P'
);
// Closest planned stop to the car's current position, used by driving mode.
const nextStop = React.useMemo(() => {
if (!isDriving || !tesla.state?.lat || !tesla.state?.lng) return null;
let best: { stop: Stop; km: number } | null = null;
for (const s of allStops) {
const km = haversineKm(
{ lat: tesla.state.lat, lng: tesla.state.lng },
{ lat: s.lat, lng: s.lng },
);
if (best === null || km < best.km) best = { stop: s, km };
}
return best;
}, [isDriving, tesla.state?.lat, tesla.state?.lng, allStops]);
return (
<ErrorBoundary>
{isDriving && (
<DrivingMode
tesla={tesla}
nextStop={nextStop}
vehicle={vehicle}
onSendToNav={async () => {
if (!nextStop) return;
const ok = await sendToTeslaNav({ lat: nextStop.stop.lat, lng: nextStop.stop.lng, name: nextStop.stop.name });
if (ok) toast.success(`Sent ${nextStop.stop.name} to Tesla nav`);
else toast.error('Could not send');
}}
onExit={() => {
// Force-exit driving mode (debug). Strips ?drivingMode=1 if present.
const url = new URL(window.location.href);
url.searchParams.delete('drivingMode');
window.history.replaceState({}, '', url.toString());
window.location.reload();
}}
/>
)}
<div className="flex flex-col h-screen overflow-hidden" style={{ background: 'var(--gd-bg)', color: 'var(--gd-text)' }}>
<TopBar
origin={origin} destination={destination}
@@ -2663,6 +2739,154 @@ export default function TeslaTripPlanner() {
);
}
// ─── Driving mode ────────────────────────────────────────────────────────────
// Full-screen overlay when the car is moving. Huge typography, minimal chrome,
// only one possible action (Send next stop to nav).
function DrivingMode({ tesla, nextStop, vehicle, onSendToNav, onExit }: {
tesla: ReturnType<typeof useTesla>;
nextStop: { stop: Stop; km: number } | null;
vehicle: Vehicle;
onSendToNav: () => void;
onExit: () => void;
}) {
const state = tesla.state;
const battery = state?.battery ?? null;
const rangeKm = state?.rangeKm ?? null;
const speed = state?.speedKmh ?? null;
const distanceKm = nextStop?.km ?? null;
const reqPctRough = distanceKm != null
? Math.max(5, Math.round((distanceKm / Math.max(50, vehicle.rangeKm)) * 100))
: null;
const enough = battery != null && reqPctRough != null ? battery >= reqPctRough + 8 : null;
// ETA roughly distance ÷ assumed 95 km/h motorway, expressed as a clock time.
const etaText = React.useMemo(() => {
if (distanceKm == null) return null;
const avgKmh = speed && speed > 30 ? Math.max(60, speed) : 95;
const minutes = Math.round((distanceKm / avgKmh) * 60);
const arrive = new Date(Date.now() + minutes * 60_000);
return `${arrive.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} · ${minutes}m`;
}, [distanceKm, speed]);
const meta = nextStop ? stopMeta(nextStop.stop.type) : null;
const Icon = meta?.icon ?? MapPin;
return (
<div
className="fixed inset-0 flex flex-col"
style={{ zIndex: 9997, background: '#0a0a0c', color: 'var(--gd-text)' }}
>
{/* Top strip — battery + speed + exit */}
<div className="px-8 py-5 flex items-center gap-6" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="flex items-center gap-3">
<div
className="grid place-items-center rounded-2xl"
style={{ width: 68, height: 44, background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border-2)' }}
>
<div className="text-[22px] font-semibold num" style={{ color: battery != null && battery < 20 ? 'var(--gd-red)' : 'var(--gd-green)' }}>
{battery != null ? `${battery}%` : ''}
</div>
</div>
<div className="leading-[1.15]">
<div className="text-[13px]" style={{ color: 'var(--gd-text-3)' }}>Range</div>
<div className="text-[20px] num font-medium">{rangeKm != null ? `${rangeKm} km` : ''}</div>
</div>
</div>
<div className="flex-1 text-center">
<div className="text-[12px] uppercase tracking-[0.2em]" style={{ color: 'var(--gd-text-3)' }}>
Driving · {state?.shiftState ?? 'D'}
</div>
<div className="text-[42px] num font-semibold leading-[1]">{speed ?? 0}</div>
<div className="text-[12px]" style={{ color: 'var(--gd-text-3)' }}>km/h</div>
</div>
<button
onClick={onExit}
className="text-[12px] px-3 py-2 rounded-lg"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
title="Exit driving mode"
>
Exit
</button>
</div>
{/* Centre — next stop hero */}
<div className="flex-1 flex flex-col items-center justify-center px-8">
{nextStop ? (
<>
<div className="text-[14px] uppercase tracking-[0.25em] mb-5" style={{ color: 'var(--gd-red)' }}>
Next stop
</div>
<div
className="w-24 h-24 rounded-3xl grid place-items-center mb-6"
style={{ background: `${meta?.color || '#9ca3af'}22`, border: `1px solid ${meta?.color || '#9ca3af'}55` }}
>
<Icon size={44} style={{ color: meta?.color }} />
</div>
<div className="text-[42px] font-semibold text-center leading-[1.1] mb-3 max-w-[80vw]">
{nextStop.stop.name}
</div>
<div className="flex items-center gap-6 mb-10">
<div className="text-center">
<div className="text-[11px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>Distance</div>
<div className="text-[34px] num font-semibold">{Math.round(distanceKm ?? 0)} km</div>
</div>
{etaText && (
<div className="text-center">
<div className="text-[11px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>Arrive</div>
<div className="text-[34px] num font-semibold">{etaText}</div>
</div>
)}
</div>
{enough != null && (
<div
className="text-[14px] px-4 py-2 rounded-full mb-6"
style={{
background: enough ? 'rgba(74,222,128,0.12)' : 'rgba(227,25,55,0.16)',
color: enough ? 'var(--gd-green)' : 'var(--gd-red)',
border: `1px solid ${enough ? 'rgba(74,222,128,0.35)' : 'rgba(227,25,55,0.45)'}`,
}}
>
{enough ? `Enough battery — ~${reqPctRough}% needed` : `Low battery — need ~${reqPctRough}% to reach this stop`}
</div>
)}
<button
onClick={onSendToNav}
className="h-16 px-10 rounded-2xl text-[20px] font-semibold inline-flex items-center gap-3"
style={{ background: 'var(--gd-red)', color: '#fff', boxShadow: '0 8px 28px rgba(227,25,55,0.35)' }}
>
<Navigation className="w-6 h-6" /> Send to Tesla nav
</button>
</>
) : (
<div className="text-center">
<div className="text-[18px] mb-2" style={{ color: 'var(--gd-text-2)' }}>No trip loaded</div>
<div className="text-[13px]" style={{ color: 'var(--gd-text-3)' }}>Plan a trip while parked and it'll pick up from here.</div>
</div>
)}
</div>
{/* Bottom — charging status if plugged in */}
{state?.chargingState === 'Charging' && (
<div className="px-8 py-4 flex items-center gap-6" style={{ borderTop: '1px solid var(--gd-border)', background: 'rgba(74,222,128,0.05)' }}>
<Zap className="w-6 h-6" style={{ color: 'var(--gd-green)' }} />
<div className="flex-1">
<div className="text-[12px] uppercase tracking-wider" style={{ color: 'var(--gd-green)' }}>Charging</div>
<div className="text-[20px] num">
{state.chargerPowerKw ?? '—'} kW
{state.timeToFullCharge != null && (
<span className="text-[13px] ml-3" style={{ color: 'var(--gd-text-3)' }}>
· {Math.round(state.timeToFullCharge * 60)}m to target
</span>
)}
</div>
</div>
</div>
)}
</div>
);
}
function OwnerLoginModal({ open, onClose, onSuccess }: {
open: boolean; onClose: () => void; onSuccess: () => void;
}) {