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; }) {