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:
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Tesla Trip Planner • Grok</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
|
||||
Generated
+4334
-89
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tonycodes/auth-react": "^1.4.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"framer-motion": "^11.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.303.0",
|
||||
@@ -20,10 +21,10 @@
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"sonner": "^1.4.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"dompurify": "^3.2.4"
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.47",
|
||||
@@ -34,6 +35,7 @@
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.8",
|
||||
"@types/dompurify": "^3.0.5"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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
@@ -1,13 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
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 './styles/globals.css';
|
||||
import { detectInCar, applyInCarClass } from './lib/incar';
|
||||
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -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 { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock';
|
||||
import { detectInCar } from '../lib/incar';
|
||||
import { computeBatteryPlan, type BatteryAtStop, type BatteryRisk } from '../lib/batteryPlan';
|
||||
import L from 'leaflet';
|
||||
import {
|
||||
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;
|
||||
const isRisky = risk === 'warn' || risk === 'danger';
|
||||
const tone = risk === 'danger' ? 'var(--gd-red)'
|
||||
: risk === 'warn' ? 'var(--gd-amber)'
|
||||
: 'var(--gd-text-3)';
|
||||
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" />
|
||||
<span>{formatKm(leg.distanceKm)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</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>
|
||||
);
|
||||
}
|
||||
@@ -2333,6 +2369,21 @@ export default function TeslaTripPlanner() {
|
||||
const isDriving = forceDrivingMode || (
|
||||
!!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.
|
||||
const nextStop = React.useMemo(() => {
|
||||
if (!isDriving || !tesla.state?.lat || !tesla.state?.lng) return null;
|
||||
@@ -2627,6 +2678,46 @@ export default function TeslaTripPlanner() {
|
||||
</button>
|
||||
</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 */}
|
||||
{itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && (
|
||||
<button
|
||||
@@ -2729,7 +2820,11 @@ export default function TeslaTripPlanner() {
|
||||
}}
|
||||
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 && (
|
||||
<NightBlock
|
||||
lastStop={stop}
|
||||
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
+70
-1
@@ -1,9 +1,78 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
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: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user