From 5b97f5b8737a546170b0e639a97e59013973213b Mon Sep 17 00:00:00 2001 From: Tony James Date: Sun, 31 May 2026 22:41:24 +0100 Subject: [PATCH] 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". --- client/src/lib/tesla.ts | 17 +- client/src/lib/teslaMock.ts | 157 ++++++++++++++++++ client/src/pages/TeslaTripPlanner.tsx | 224 ++++++++++++++++++++++++++ 3 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 client/src/lib/teslaMock.ts diff --git a/client/src/lib/tesla.ts b/client/src/lib/tesla.ts index b8dc157..c92d1c3 100644 --- a/client/src/lib/tesla.ts +++ b/client/src/lib/tesla.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { isMockEnabled, getMockStatus, getMockState } from './teslaMock'; export interface TeslaStatus { available: boolean; @@ -18,6 +19,7 @@ export interface OwnerStatus { } export async function fetchOwnerStatus(): Promise { + if (isMockEnabled()) return { authenticated: true, required: true }; const res = await fetch('/api/auth/owner/status'); if (!res.ok) return { authenticated: false, required: false }; return res.json(); @@ -60,12 +62,14 @@ export interface TeslaState { } export async function fetchTeslaStatus(): Promise { + if (isMockEnabled()) return getMockStatus(); const res = await fetch('/api/tesla/status'); if (!res.ok) return { available: false, connected: false }; return res.json(); } export async function fetchTeslaState(): Promise { + if (isMockEnabled()) return getMockState(); const res = await fetch('/api/tesla/state'); if (res.status === 202) return null; // asleep if (!res.ok) return null; @@ -73,11 +77,16 @@ export async function fetchTeslaState(): Promise { } export async function wakeTesla(): Promise { + if (isMockEnabled()) return true; const res = await fetch('/api/tesla/wake', { method: 'POST' }); return res.ok; } export async function startTeslaConnect(): Promise { + if (isMockEnabled()) { + window.location.href = `${window.location.pathname}?tesla_connected=1`; + return; + } const res = await fetch('/api/auth/tesla/start'); if (!res.ok) throw new Error('Could not start Tesla OAuth'); const data = await res.json(); @@ -85,10 +94,15 @@ export async function startTeslaConnect(): Promise { } export async function disconnectTesla(): Promise { + if (isMockEnabled()) return; await fetch('/api/tesla/disconnect', { method: 'POST' }); } export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise { + if (isMockEnabled()) { + console.log('[mock] sendToTeslaNav', args); + return true; + } const res = await fetch('/api/tesla/send-to-nav', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -133,7 +147,8 @@ export function useTesla() { } }; tick(); - pollRef.current = setInterval(tick, 60_000); + // Poll faster when mocking so the driving scenario animates visibly. + pollRef.current = setInterval(tick, isMockEnabled() ? 3_000 : 60_000); return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; }, [status?.connected, owner?.authenticated]); diff --git a/client/src/lib/teslaMock.ts b/client/src/lib/teslaMock.ts new file mode 100644 index 0000000..7de9675 --- /dev/null +++ b/client/src/lib/teslaMock.ts @@ -0,0 +1,157 @@ +// Client-side mock for the Tesla integration so we can iterate on UI without +// burning Fleet API calls (or sitting in the car). +// +// Activate via URL: +// ?mockTesla=parked parked, 65% battery, in Milton Keynes +// ?mockTesla=driving moving at 110 km/h, battery slowly draining +// ?mockTesla=charging plugged in at a Supercharger, 132 kW +// ?mockTesla=asleep connected but car asleep (state returns null) +// ?mockTesla=off disable mock (delete the stored flag) +// +// The flag persists in localStorage so reloads keep the scenario. +// Dev-only: import.meta.env.PROD short-circuits this off in the bundled build, +// so production users can't enable mock mode. + +import type { TeslaStatus, TeslaState } from './tesla'; + +export type MockScenario = 'parked' | 'driving' | 'charging' | 'asleep'; + +const STORAGE_KEY = 'mockTesla'; + +export function isMockEnabled(): boolean { + return !!getMockScenario(); +} + +export function getMockScenario(): MockScenario | null { + if (typeof window === 'undefined') return null; + if (import.meta.env.PROD) return null; // never in prod build + + const urlParam = new URLSearchParams(window.location.search).get('mockTesla'); + if (urlParam === 'off') { + window.localStorage.removeItem(STORAGE_KEY); + return null; + } + if (urlParam && isValidScenario(urlParam)) { + window.localStorage.setItem(STORAGE_KEY, urlParam); + return urlParam; + } + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored && isValidScenario(stored) ? stored : null; +} + +function isValidScenario(s: string): s is MockScenario { + return s === 'parked' || s === 'driving' || s === 'charging' || s === 'asleep'; +} + +export function setMockScenario(s: MockScenario | null): void { + if (typeof window === 'undefined') return; + if (s == null) window.localStorage.removeItem(STORAGE_KEY); + else window.localStorage.setItem(STORAGE_KEY, s); +} + +// ─── Canned vehicle identity ───────────────────────────────────────────────── +const MOCK_VEHICLE = { + vin: '5YJ3E1EA1KF0MOCKED', + carType: 'modely', + trimBadging: 'lrawd', + vehicleName: 'Mocky', + softwareVersion: '2026.5.14', +}; + +// Base location: Milton Keynes, MK7 8PJ +const BASE_LAT = 52.0186; +const BASE_LNG = -0.7064; + +// Drift state for the "driving" scenario — battery slowly drains, position +// shuffles a bit so map markers move. +let driveBattery = 64; +let driveTickCount = 0; + +export function getMockStatus(): TeslaStatus { + const s = getMockScenario(); + if (!s) return { available: true, connected: false, ownerAuthenticated: true }; + return { + available: true, + connected: true, + ownerAuthenticated: true, + connectedAt: Date.now() - 60 * 60 * 1000, + vehicleId: 'mock-1', + vin: `…${MOCK_VEHICLE.vin.slice(-4)}`, + carType: MOCK_VEHICLE.carType, + trimBadging: MOCK_VEHICLE.trimBadging, + }; +} + +export function getMockState(): TeslaState | null { + const s = getMockScenario(); + if (!s || s === 'asleep') return null; + + const common = { + connected: true, + asleep: false, + carType: MOCK_VEHICLE.carType, + trimBadging: MOCK_VEHICLE.trimBadging, + vin: `…${MOCK_VEHICLE.vin.slice(-4)}`, + vehicleName: MOCK_VEHICLE.vehicleName, + softwareVersion: MOCK_VEHICLE.softwareVersion, + odometerKm: 24_500, + heading: s === 'driving' ? 92 : 0, + fetchedAt: Date.now(), + }; + + if (s === 'parked') { + return { + ...common, + battery: 65, + rangeKm: 320, + idealRangeKm: 330, + chargingState: 'Disconnected', + chargerPowerKw: null, + timeToFullCharge: null, + lat: BASE_LAT, + lng: BASE_LNG, + speedKmh: 0, + shiftState: 'P' as const, + }; + } + + if (s === 'charging') { + return { + ...common, + battery: 47, + rangeKm: 240, + idealRangeKm: 250, + chargingState: 'Charging', + chargerPowerKw: 132, + timeToFullCharge: 0.55, // ~33 min + lat: 52.0461, // Baldock services + lng: -0.2024, + speedKmh: 0, + shiftState: 'P' as const, + }; + } + + // driving — animate battery + position with each tick + driveTickCount++; + driveBattery = Math.max(8, driveBattery - 0.18); + return { + ...common, + battery: Math.round(driveBattery), + rangeKm: Math.round(driveBattery * 4.9), + idealRangeKm: Math.round(driveBattery * 5.1), + chargingState: 'Disconnected', + chargerPowerKw: null, + timeToFullCharge: null, + // Crude eastbound drift across the East Anglian flatlands. + lat: BASE_LAT - 0.002 * driveTickCount, + lng: BASE_LNG + 0.008 * driveTickCount, + speedKmh: 112, + shiftState: 'D' as const, + }; +} + +/** Reset the running driving simulation back to start. */ +export function resetMockDrive(): void { + driveBattery = 64; + driveTickCount = 0; +} diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index e9c4c13..de63ceb 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -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} )} + + + ); +} + +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 ( +
+ MOCK + +
); } @@ -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 ( + {isDriving && ( + { + 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(); + }} + /> + )}
; + 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 ( +
+ {/* Top strip — battery + speed + exit */} +
+
+
+
+ {battery != null ? `${battery}%` : '—'} +
+
+
+
Range
+
{rangeKm != null ? `${rangeKm} km` : '—'}
+
+
+ +
+
+ Driving · {state?.shiftState ?? 'D'} +
+
{speed ?? 0}
+
km/h
+
+ + +
+ + {/* Centre — next stop hero */} +
+ {nextStop ? ( + <> +
+ Next stop +
+
+ +
+
+ {nextStop.stop.name} +
+
+
+
Distance
+
{Math.round(distanceKm ?? 0)} km
+
+ {etaText && ( +
+
Arrive
+
{etaText}
+
+ )} +
+ {enough != null && ( +
+ {enough ? `Enough battery — ~${reqPctRough}% needed` : `Low battery — need ~${reqPctRough}% to reach this stop`} +
+ )} + + + ) : ( +
+
No trip loaded
+
Plan a trip while parked and it'll pick up from here.
+
+ )} +
+ + {/* Bottom — charging status if plugged in */} + {state?.chargingState === 'Charging' && ( +
+ +
+
Charging
+
+ {state.chargerPowerKw ?? '—'} kW + {state.timeToFullCharge != null && ( + + · {Math.round(state.timeToFullCharge * 60)}m to target + + )} +
+
+
+ )} +
+ ); +} + function OwnerLoginModal({ open, onClose, onSuccess }: { open: boolean; onClose: () => void; onSuccess: () => void; }) {