feat: battery-aware itinerary nudge + PWA / offline support

Battery nudge:
- lib/batteryPlan: project SoC through the planned stops using current
  Tesla battery (or 80% default), 85% of rated range, and conservative
  per-stop charge rates (Supercharger 1.5%/min, dest charger 0.4%/min).
- LegRow shows "arrive X%" plus a tone (red/amber/transparent) and a
  TIGHT / WON'T REACH chip if the projected arrival is below 15%/5%.
- Top-of-itinerary banner when any leg is danger or warning, naming the
  current SoC source (live Tesla vs assumed 80%).

PWA / offline:
- vite-plugin-pwa with autoUpdate strategy; ServiceWorker registered in
  main.tsx (prod-only).
- Workbox cache strategies:
    map tiles      CacheFirst, 30 days, 800 entries
    nominatim/OSRM NetworkFirst with cached fallback
    /api/*         NetworkFirst, short TTL, last response stays usable
- App shell precached so the planner renders offline; navigateFallback
  ensures deep links serve index.html without a network round trip.
- manifest.webmanifest + theme-color + favicon.svg (replaces dead
  /tesla-icon.svg reference). Installable to home screen.
- Sonner toasts on offline-ready and update-available.
This commit is contained in:
2026-05-31 23:07:34 +01:00
parent 381eb18cd3
commit a90c8a9354
9 changed files with 4628 additions and 98 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#0a0a0c" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tesla Trip Planner • Grok</title> <title>Tesla Trip Planner • Grok</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+4334 -89
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tonycodes/auth-react": "^1.4.0", "@tonycodes/auth-react": "^1.4.0",
"dompurify": "^3.2.4",
"framer-motion": "^11.0.0", "framer-motion": "^11.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
@@ -20,10 +21,10 @@
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.21.1", "react-router-dom": "^6.21.1",
"sonner": "^1.4.0", "sonner": "^1.4.0",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.2.0"
"dompurify": "^3.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.14", "@types/leaflet": "^1.9.14",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/react": "^18.2.47", "@types/react": "^18.2.47",
@@ -34,6 +35,7 @@
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"vite": "^5.0.8", "vite": "^5.0.8",
"@types/dompurify": "^3.0.5" "vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
} }
} }
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#e31937"/>
<path d="M9 21l5.5-10h3L23 21l-2.7-1.5L17 14l-3.5 5.5L9 21z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

+92
View File
@@ -0,0 +1,92 @@
// Simple charge-aware battery projection through an itinerary.
//
// Inputs:
// - startSoC starting battery % (Tesla live SoC if connected, else 80)
// - effectiveRangeKm the realistic full-charge range (~85% of rated)
// - stops + legs in planned order
//
// Output: per-stop { arriveSoC, leaveSoC, risk }, plus a flag if any leg
// would arrive below the danger threshold.
// Structural shapes — we only need the fields we use, so we don't drag the
// full Stop/Leg types out of TeslaTripPlanner.
interface BPStop {
id: string;
type: string;
chargeMinutes?: number;
}
interface BPLeg {
fromId: string;
distanceKm: number | null;
}
export type BatteryRisk = 'ok' | 'warn' | 'danger';
export interface BatteryAtStop {
stopId: string;
arriveSoC: number; // % on arrival (can dip negative if range exhausted)
leaveSoC: number; // % after planned charging
risk: BatteryRisk;
}
export interface BatteryPlan {
perStop: BatteryAtStop[];
hasDanger: boolean; // any stop with arriveSoC < DANGER_THRESHOLD
hasWarning: boolean; // any stop with arriveSoC < WARN_THRESHOLD
}
const WARN_THRESHOLD = 15;
const DANGER_THRESHOLD = 5;
// Rough charge rates per stop type. Realistic, conservative.
function chargeRatePctPerMin(type: string): number {
switch (type) {
case 'supercharger': return 1.5; // ~250 kW peak, average ~1.5%/min
case 'destination-charger': return 0.4; // 11-22 kW
case 'hotel': return 0.4; // assume destination charging
default: return 0;
}
}
export function computeBatteryPlan(
stops: BPStop[],
legs: BPLeg[],
startSoC: number,
effectiveRangeKm: number,
): BatteryPlan {
const legByFromId = new Map<string, BPLeg>();
for (const l of legs) legByFromId.set(l.fromId, l);
const perStop: BatteryAtStop[] = [];
let current = startSoC;
let hasDanger = false;
let hasWarning = false;
for (let i = 0; i < stops.length; i++) {
const stop = stops[i]!;
let arrive = current;
if (i > 0) {
const prev = stops[i - 1]!;
const leg = legByFromId.get(prev.id);
const km = leg?.distanceKm ?? 0;
const drop = effectiveRangeKm > 0 ? (km / effectiveRangeKm) * 100 : 0;
arrive = current - drop;
}
const risk: BatteryRisk = arrive < DANGER_THRESHOLD ? 'danger'
: arrive < WARN_THRESHOLD ? 'warn'
: 'ok';
if (risk === 'danger') hasDanger = true;
if (risk !== 'ok') hasWarning = true;
// Apply planned charging at this stop. Cap at 100, ignore if no charge planned.
const chargeMin = stop.chargeMinutes ?? 0;
const rate = chargeRatePctPerMin(stop.type);
const leave = Math.min(100, Math.max(arrive, arrive + chargeMin * rate));
perStop.push({ stopId: stop.id, arriveSoC: arrive, leaveSoC: leave, risk });
current = leave;
}
return { perStop, hasDanger, hasWarning };
}
+21 -1
View File
@@ -1,13 +1,33 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner'; import { Toaster, toast } from 'sonner';
import { registerSW } from 'virtual:pwa-register';
import App from './App'; import App from './App';
import './styles/globals.css'; import './styles/globals.css';
import { detectInCar, applyInCarClass } from './lib/incar'; import { detectInCar, applyInCarClass } from './lib/incar';
applyInCarClass(detectInCar()); applyInCarClass(detectInCar());
// Register the service worker. autoUpdate strategy means a refresh picks up
// the new build immediately — we still show a toast so the user knows.
if (import.meta.env.PROD) {
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
toast('New version available', {
action: { label: 'Reload', onClick: () => updateSW(true) },
duration: Infinity,
});
},
onOfflineReady() {
toast.success('Ready to use offline', {
description: 'Your planned trip + map tiles will be available without signal.',
});
},
});
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
+98 -3
View File
@@ -4,6 +4,7 @@ import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner, type TeslaActiveRoute } 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 { computeBatteryPlan, type BatteryAtStop, type BatteryRisk } from '../lib/batteryPlan';
import L from 'leaflet'; import L from 'leaflet';
import { import {
Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X, Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X,
@@ -671,14 +672,49 @@ function ChipButton({ children, onClick, className = '' }: { children: React.Rea
); );
} }
function LegRow({ leg }: { leg: Leg | undefined }) { function LegRow({ leg, arrivalSoC, risk }: {
leg: Leg | undefined;
arrivalSoC?: number;
risk?: BatteryRisk;
}) {
if (!leg) return null; if (!leg) return null;
const isRisky = risk === 'warn' || risk === 'danger';
const tone = risk === 'danger' ? 'var(--gd-red)'
: risk === 'warn' ? 'var(--gd-amber)'
: 'var(--gd-text-3)';
return ( return (
<div className="flex items-center gap-3 pl-[52px] pr-3 py-2 text-[11px] num" style={{ color: 'var(--gd-text-3)' }}> <div
className="flex items-center gap-3 pl-[52px] pr-3 py-2 text-[11px] num"
style={{
color: tone,
background: isRisky ? (risk === 'danger' ? 'rgba(227,25,55,0.08)' : 'rgba(251,191,36,0.08)') : 'transparent',
borderLeft: isRisky ? `2px solid ${tone}` : '2px solid transparent',
}}
>
<Route className="w-3 h-3" /> <Route className="w-3 h-3" />
<span>{formatKm(leg.distanceKm)}</span> <span>{formatKm(leg.distanceKm)}</span>
<span style={{ opacity: 0.4 }}>·</span> <span style={{ opacity: 0.4 }}>·</span>
<span>{formatDuration(leg.durationMin)} drive</span> <span>{formatDuration(leg.durationMin)} drive</span>
{arrivalSoC != null && (
<>
<span style={{ opacity: 0.4 }}>·</span>
<span style={{ fontWeight: isRisky ? 600 : 400 }}>
arrive {Math.max(0, Math.round(arrivalSoC))}%
</span>
</>
)}
{risk === 'danger' && (
<span className="ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded uppercase text-[9.5px] font-semibold tracking-wider"
style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}>
<AlertTriangle className="w-2.5 h-2.5" /> won't reach
</span>
)}
{risk === 'warn' && (
<span className="ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded uppercase text-[9.5px] font-semibold tracking-wider"
style={{ background: 'rgba(251,191,36,0.15)', color: 'var(--gd-amber)' }}>
<AlertTriangle className="w-2.5 h-2.5" /> tight
</span>
)}
</div> </div>
); );
} }
@@ -2333,6 +2369,21 @@ export default function TeslaTripPlanner() {
const isDriving = forceDrivingMode || ( const isDriving = forceDrivingMode || (
!!tesla.state?.shiftState && tesla.state.shiftState !== 'P' !!tesla.state?.shiftState && tesla.state.shiftState !== 'P'
); );
// Project SoC through the itinerary so we can flag risky legs.
const batteryPlan = React.useMemo(() => {
if (allStops.length === 0) return null;
const startSoC = tesla.state?.battery ?? 80;
// 85% of rated range is a realistic motorway figure (HVAC, motorway speeds, weather).
const effectiveRangeKm = (vehicle.rangeKm || 400) * 0.85;
return computeBatteryPlan(allStops, legs, startSoC, effectiveRangeKm);
}, [allStops, legs, tesla.state?.battery, vehicle.rangeKm]);
const batteryByStop = React.useMemo(() => {
const m = new Map<string, BatteryAtStop>();
if (batteryPlan) for (const entry of batteryPlan.perStop) m.set(entry.stopId, entry);
return m;
}, [batteryPlan]);
// Closest planned stop to the car's current position, used by driving mode. // Closest planned stop to the car's current position, used by driving mode.
const nextStop = React.useMemo(() => { const nextStop = React.useMemo(() => {
if (!isDriving || !tesla.state?.lat || !tesla.state?.lng) return null; if (!isDriving || !tesla.state?.lat || !tesla.state?.lng) return null;
@@ -2627,6 +2678,46 @@ export default function TeslaTripPlanner() {
</button> </button>
</div> </div>
{/* Battery nudge — only fires when the plan would actually leave the driver stranded. */}
{batteryPlan?.hasDanger && allStops.length > 0 && (
<div
className="mx-4 mt-3 px-3 py-2 rounded-lg flex items-center gap-2.5"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--gd-red)' }} />
<div className="flex-1 min-w-0">
<div className="text-[11.5px] font-medium" style={{ color: 'var(--gd-red)' }}>
Battery won't reach every stop
</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>
{tesla.state?.battery != null
? `Starting at your live ${tesla.state.battery}% — `
: 'Assumed 80% start — '}
add a charger before the legs flagged below or ask Grok to replan.
</div>
</div>
</div>
)}
{batteryPlan?.hasWarning && !batteryPlan?.hasDanger && allStops.length > 0 && (
<div
className="mx-4 mt-3 px-3 py-2 rounded-lg flex items-center gap-2.5"
style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.4)' }}
>
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--gd-amber)' }} />
<div className="flex-1 min-w-0">
<div className="text-[11.5px] font-medium" style={{ color: 'var(--gd-amber)' }}>
Tight charge on some legs
</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>
{tesla.state?.battery != null
? `Starting at your live ${tesla.state.battery}%. `
: 'Assumed 80% start. '}
Cold weather or motorway speeds could push these legs into the red.
</div>
</div>
</div>
)}
{/* Travel-dates nudge */} {/* Travel-dates nudge */}
{itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && ( {itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && (
<button <button
@@ -2729,7 +2820,11 @@ export default function TeslaTripPlanner() {
}} }}
onDragEnd={() => setDraggingId(null)} onDragEnd={() => setDraggingId(null)}
/> />
{!isLast && <LegRow leg={leg} />} {!isLast && (() => {
const nextStopId = stops[si + 1]?.id;
const battNext = nextStopId ? batteryByStop.get(nextStopId) : undefined;
return <LegRow leg={leg} arrivalSoC={battNext?.arriveSoC} risk={battNext?.risk} />;
})()}
{showNightBlock && ( {showNightBlock && (
<NightBlock <NightBlock
lastStop={stop} lastStop={stop}
+1
View File
@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+70 -1
View File
@@ -1,9 +1,78 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { resolve } from 'path'; import { resolve } from 'path';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: 'Grok Drive',
short_name: 'GrokDrive',
description: 'AI-assisted Tesla road trip planner',
theme_color: '#0a0a0c',
background_color: '#0a0a0c',
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
icons: [
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,ico,woff,woff2}'],
globIgnores: ['**/*.map'],
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//, /^\/\.well-known\//],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
runtimeCaching: [
// Map tiles — cache-first for offline viewing of the planned route.
{
urlPattern: ({ url }: { url: URL }) =>
url.hostname.endsWith('tile.openstreetmap.org')
|| url.hostname.endsWith('basemaps.cartocdn.com')
|| /tile/i.test(url.hostname),
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 800, maxAgeSeconds: 60 * 60 * 24 * 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Geocoding + routing APIs — network-first, with cached fallback.
{
urlPattern: ({ url }: { url: URL }) =>
url.hostname.endsWith('nominatim.openstreetmap.org')
|| url.hostname.endsWith('router.project-osrm.org'),
handler: 'NetworkFirst',
options: {
cacheName: 'geo-api',
networkTimeoutSeconds: 4,
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Our own API — network-first, cached short-term so the last good
// response stays visible when LTE drops.
{
urlPattern: ({ url }: { url: URL }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',
options: {
cacheName: 'app-api',
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
devOptions: { enabled: false },
}),
],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),