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">
|
<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"
|
||||||
|
|||||||
Generated
+4334
-89
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Vendored
+1
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|||||||
+70
-1
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user