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
+70 -1
View File
@@ -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'),