feat: travel dates + sea-crossing chooser, Tesla in-car polish, Fleet API stub

- Travel dates: TopBar chip + popover (outbound/return/travellers); sent to
  Grok prompt; itinerary.needsTravelDates drives a nudge banner; cache and
  prefetch ledger invalidate when dates change
- Sea crossings: CrossingOption schema (Eurotunnel, DFDS, P&O, Brittany,
  Stena Line); CrossingSwapBlock under tunnel/ferry/crossing stops with
  trip-impact deltas and Book links; prompt requires 3-5 real options for
  every UK ↔ mainland route; picking a crossing triggers silent re-plan
- Tesla in-car polish: UA + heuristic detection sets <html class="incar">;
  CSS overrides kill backdrop-filter, scale fonts, enforce 44px tap targets,
  disable hover flicker; geolocation + reverse geocode + crosshair button
  inside the From input; up/down arrow reorder buttons replace touch-broken
  HTML5 drag-and-drop
- Tesla Fleet API stub: /.well-known/appspecific/com.tesla.3p.public-key.pem
  served from TESLA_FLEET_PUBLIC_KEY for partner domain verification;
  OAuth callback + vehicle_data stub return 503 until partner approval
- Dockerfile + .dockerignore for Dokku deployment; server now serves
  client/dist in production
This commit is contained in:
2026-05-31 21:38:27 +01:00
parent 88fc86dc32
commit cff52b4b9e
11 changed files with 885 additions and 24 deletions
+45
View File
@@ -0,0 +1,45 @@
// Detect Tesla in-car browser + general touch-only large-screen mode.
// Tesla MCU2 reports a "QtCarBrowser" UA fragment; MCU3 reports a more standard
// Chromium UA but with "Tesla" in some firmware. We also honour ?incar=1 for
// testing on a regular browser, and fall back to a touch + landscape-tablet
// heuristic so the optimisations apply to anything that looks like a car dash.
export interface InCarInfo {
/** True if we're inside (or simulating) a Tesla in-car browser. */
isTesla: boolean;
/** True if we should apply the heavyweight "car dash" UX (big text, no blur, tap-friendly). */
isInCar: boolean;
/** Approximate MCU generation hint when detectable. */
mcu: 'mcu2' | 'mcu3' | 'unknown';
}
export function detectInCar(): InCarInfo {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return { isTesla: false, isInCar: false, mcu: 'unknown' };
}
const ua = navigator.userAgent || '';
const isQt = /QtCarBrowser/i.test(ua);
const isTeslaUa = /Tesla/i.test(ua);
const forced = new URLSearchParams(window.location.search).get('incar') === '1';
const isTesla = isQt || isTeslaUa || forced;
// Heuristic touch-only tablet/dash mode: coarse pointer, ≥1200px width, no fine pointer.
const coarse = window.matchMedia?.('(pointer: coarse)').matches ?? false;
const wide = window.innerWidth >= 1200;
const heuristicInCar = coarse && wide;
return {
isTesla,
isInCar: isTesla || forced || heuristicInCar,
mcu: isQt ? 'mcu2' : isTeslaUa ? 'mcu3' : 'unknown',
};
}
/** Apply or remove the body class side effects (idempotent). */
export function applyInCarClass(info: InCarInfo) {
const root = document.documentElement;
root.classList.toggle('incar', info.isInCar);
root.classList.toggle('tesla', info.isTesla);
root.classList.toggle('tesla-mcu2', info.mcu === 'mcu2');
root.classList.toggle('tesla-mcu3', info.mcu === 'mcu3');
}
+3
View File
@@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner';
import App from './App';
import './styles/globals.css';
import { detectInCar, applyInCarClass } from './lib/incar';
applyInCarClass(detectInCar());
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+534 -14
View File
@@ -7,6 +7,7 @@ import {
Plus, ArrowLeftRight, Settings2, AlertTriangle, Gauge, Trash2,
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
} from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops)
@@ -18,7 +19,7 @@ L.Icon.Default.mergeOptions({
});
// ─── Types ───────────────────────────────────────────────────────────────────
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel';
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom' | 'origin' | 'destination' | 'tunnel' | 'ferry' | 'crossing';
interface NearbyPlace {
category: 'food' | 'do' | 'see' | 'shop' | 'rest';
@@ -39,6 +40,24 @@ interface ChargerOption {
badge?: string | null;
}
interface CrossingOption {
id: string;
operator: string; // e.g. "Eurotunnel Le Shuttle", "DFDS", "P&O Ferries", "Brittany Ferries"
mode: 'tunnel' | 'ferry';
fromPort: string; // e.g. "Folkestone, UK"
toPort: string; // e.g. "Coquelles (Calais), FR"
durationMin: number;
priceEur: number; // ballpark single-vehicle one-way price
frequency?: string; // e.g. "every 30 min, 24/7"
pros?: string[];
cons?: string[];
badge?: 'Fastest' | 'Cheapest' | 'Most scenic' | 'Overnight' | 'Frequent' | null;
detourMin?: number; // vs the chosen crossing (in road-driving terms)
detourKm?: number;
isCurrent?: boolean;
bookingUrl?: string;
}
interface RouteVariant {
id: 'fast' | 'scenic' | 'cheap';
label: string;
@@ -89,6 +108,13 @@ interface Stop {
alternatives?: AlternativeStop[];
nearby?: NearbyPlace[];
chargerOptions?: ChargerOption[];
crossingOptions?: CrossingOption[];
}
interface TravelDates {
outbound: string | null; // ISO yyyy-mm-dd
return: string | null; // ISO yyyy-mm-dd (null if one-way)
travellers?: number;
}
interface Itinerary {
@@ -101,6 +127,7 @@ interface Itinerary {
hotels: number;
highlights?: string[];
};
needsTravelDates?: boolean;
}
interface Leg {
@@ -116,7 +143,7 @@ const EMPTY_ITINERARY: Itinerary = {
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0, highlights: [] },
};
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel'];
const STOP_TYPES: StopType[] = ['supercharger', 'destination-charger', 'hotel', 'attraction', 'restaurant', 'cafe', 'viewpoint', 'custom', 'origin', 'destination', 'tunnel', 'ferry', 'crossing'];
interface VehicleTrim {
id: string;
@@ -223,6 +250,8 @@ function stopMeta(type: StopType): { icon: React.ComponentType<{ className?: str
case 'origin': return { icon: HomeIcon, color: '#9ca3af' };
case 'destination': return { icon: Flag, color: 'var(--gd-red)' };
case 'tunnel': return { icon: Route, color: '#a78bfa' };
case 'ferry':
case 'crossing': return { icon: Ship, color: '#60a5fa' };
case 'supercharger':
case 'destination-charger': return { icon: Zap, color: '#4ade80' };
case 'hotel': return { icon: Bed, color: '#60a5fa' };
@@ -256,6 +285,41 @@ async function geocodeLocation(query: string): Promise<{ lat: number; lng: numbe
return null;
}
// Reverse geocode lat/lng → human-readable place name (Nominatim).
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=14`;
const res = await fetch(url, { headers: { 'User-Agent': 'TeslaRoadtripPlanner/1.0 (local)' } });
const data = await res.json();
if (!data) return null;
const a = data.address || {};
// Build something like "Milton Keynes, MK7 8PJ" — town + postcode if available.
const town = a.city || a.town || a.village || a.hamlet || a.suburb || a.county || '';
const postcode = a.postcode || '';
const country = a.country_code ? a.country_code.toUpperCase() : '';
const parts = [town, postcode, country].filter(Boolean);
return parts.join(', ') || data.display_name || null;
} catch {
console.warn('[TeslaTrip] Reverse geocoding failed', lat, lng);
return null;
}
}
// Browser geolocation wrapped in a promise with a short timeout — Tesla's
// in-car browser sometimes hangs forever waiting for a fix.
async function getBrowserLocation(timeoutMs = 8000): Promise<GeolocationCoordinates | null> {
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
return new Promise<GeolocationCoordinates | null>((resolve) => {
let settled = false;
const timer = setTimeout(() => { if (!settled) { settled = true; resolve(null); } }, timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => { if (!settled) { settled = true; clearTimeout(timer); resolve(pos.coords); } },
() => { if (!settled) { settled = true; clearTimeout(timer); resolve(null); } },
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 30_000 },
);
});
}
// ─── Itinerary normalization (preserves all enrichment fields) ───────────────
async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (!raw || !Array.isArray(raw.days)) return EMPTY_ITINERARY;
@@ -330,6 +394,28 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
}))
: [];
const cleanCrossings: CrossingOption[] = Array.isArray(s.crossingOptions)
? s.crossingOptions
.filter((c: any) => c && typeof c.operator === 'string')
.map((c: any): CrossingOption => ({
id: c.id || `crossing-${Date.now()}-${Math.random()}`,
operator: c.operator,
mode: c.mode === 'tunnel' ? 'tunnel' : 'ferry',
fromPort: typeof c.fromPort === 'string' ? c.fromPort : '',
toPort: typeof c.toPort === 'string' ? c.toPort : '',
durationMin: typeof c.durationMin === 'number' ? c.durationMin : 0,
priceEur: typeof c.priceEur === 'number' ? c.priceEur : 0,
frequency: typeof c.frequency === 'string' ? c.frequency : undefined,
pros: Array.isArray(c.pros) ? c.pros.filter((p: unknown) => typeof p === 'string') : undefined,
cons: Array.isArray(c.cons) ? c.cons.filter((p: unknown) => typeof p === 'string') : undefined,
badge: typeof c.badge === 'string' ? c.badge as CrossingOption['badge'] : null,
detourMin: typeof c.detourMin === 'number' ? c.detourMin : undefined,
detourKm: typeof c.detourKm === 'number' ? c.detourKm : undefined,
isCurrent: c.isCurrent === true,
bookingUrl: typeof c.bookingUrl === 'string' ? c.bookingUrl : undefined,
}))
: [];
const shared = {
estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes,
@@ -343,6 +429,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
alternatives: cleanAlts.length > 0 ? cleanAlts : undefined,
nearby: cleanNearby.length > 0 ? cleanNearby : undefined,
chargerOptions: cleanChargers.length > 0 ? cleanChargers : undefined,
crossingOptions: cleanCrossings.length > 0 ? cleanCrossings : undefined,
};
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
@@ -380,6 +467,7 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [],
},
needsTravelDates: raw.needsTravelDates === true,
};
}
@@ -416,6 +504,7 @@ function normalizePartialItinerary(raw: any): Itinerary {
alternatives: undefined, // skip during partial — final event populates
nearby: Array.isArray(s.nearby) ? s.nearby.filter((n: any) => n && typeof n.name === 'string') : undefined,
chargerOptions: Array.isArray(s.chargerOptions) ? s.chargerOptions : undefined,
crossingOptions: Array.isArray(s.crossingOptions) ? s.crossingOptions : undefined,
});
}
if (validStops.length > 0) {
@@ -440,6 +529,7 @@ function normalizePartialItinerary(raw: any): Itinerary {
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [],
},
needsTravelDates: raw.needsTravelDates === true,
};
}
@@ -517,6 +607,28 @@ function formatDelta(value: number | undefined, unit: 'km' | 'min'): string | nu
return `${rounded > 0 ? '+' : ''}${rounded} ${unit}`;
}
function formatDateShort(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
function formatDateRange(outbound: string | null | undefined, ret: string | null | undefined): string {
if (!outbound) return 'Add dates';
if (!ret) return `${formatDateShort(outbound)} · one-way`;
return `${formatDateShort(outbound)}${formatDateShort(ret)}`;
}
function nightsBetween(outbound: string | null | undefined, ret: string | null | undefined): number | null {
if (!outbound || !ret) return null;
const a = new Date(outbound);
const b = new Date(ret);
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return null;
const diff = Math.round((b.getTime() - a.getTime()) / 86400000);
return diff > 0 ? diff : null;
}
// ─── Error Boundary ──────────────────────────────────────────────────────────
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
constructor(props: any) { super(props); this.state = { hasError: false }; }
@@ -870,13 +982,15 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic
);
}
function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing }: {
stop: Stop;
onSwap: (alt: AlternativeStop) => void;
onRemove: () => void;
onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void;
}) {
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry';
const arrive = typeof stop.estArrivalBattery === 'number' ? stop.estArrivalBattery : null;
const charge = typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 ? stop.chargeMinutes : null;
const leave = arrive != null && charge != null ? Math.min(100, arrive + Math.round(charge * 1.1)) : null;
@@ -884,6 +998,7 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
const amenities = (stop.amenities || []).slice(0, 8);
const alts = stop.alternatives || [];
const chargers = stop.chargerOptions || [];
const crossings = stop.crossingOptions || [];
const nearby = stop.nearby || [];
const [nearbyTab, setNearbyTab] = React.useState<'all' | NearbyPlace['category']>('all');
@@ -914,6 +1029,16 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise }: {
</div>
)}
{isCrossing && crossings.length > 0 && (
<div className="mb-3.5">
<SectionLabel>Crossing</SectionLabel>
<CrossingSwapBlock
options={crossings}
onPick={(c) => onPickCrossing?.(c)}
/>
</div>
)}
{nearby.length > 0 && (
<div className="mb-3">
<div className="flex items-center justify-between mb-2">
@@ -1064,7 +1189,8 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
function StopCard({
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise,
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing,
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
onDragStart, onDragOver, onDrop, onDragEnd,
}: {
stop: Stop;
@@ -1076,6 +1202,11 @@ function StopCard({
onSwap: (alt: AlternativeStop) => void;
onRemove: () => void;
onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
@@ -1085,7 +1216,8 @@ function StopCard({
const Icon = meta.icon;
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isSleep = stop.type === 'hotel';
const isTunnel = stop.type === 'tunnel';
const isCrossingStop = stop.type === 'tunnel' || stop.type === 'ferry' || stop.type === 'crossing';
const currentCrossing = isCrossingStop ? (stop.crossingOptions?.find(o => o.isCurrent) || stop.crossingOptions?.[0]) : undefined;
return (
<div
@@ -1106,11 +1238,33 @@ function StopCard({
}}
>
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-[10px] grid place-items-center flex-shrink-0"
style={{ background: `${meta.color}22` }}
>
<Icon size={18} className="" />
<div className="flex flex-col items-center gap-1 flex-shrink-0">
<div
className="w-10 h-10 rounded-[10px] grid place-items-center"
style={{ background: `${meta.color}22` }}
>
<Icon size={18} className="" />
</div>
<div className="flex flex-col gap-0.5">
<button
onClick={(e) => { e.stopPropagation(); onMoveUp(); }}
disabled={!canMoveUp}
title="Move up"
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<ArrowUp className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onMoveDown(); }}
disabled={!canMoveDown}
title="Move down"
className="w-10 h-7 rounded-md grid place-items-center disabled:opacity-25"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)' }}
>
<ArrowDown className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -1129,8 +1283,10 @@ function StopCard({
{isSleep && (
<div className="text-[11px] num font-medium flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-blue)' }}>overnight</div>
)}
{isTunnel && (
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>£89 · 35m</div>
{isCrossingStop && currentCrossing && (
<div className="text-[11px] num flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--gd-text-3)' }}>
{Math.round(currentCrossing.priceEur)} · {formatDuration(currentCrossing.durationMin)}
</div>
)}
</div>
{stop.combo && (
@@ -1142,7 +1298,7 @@ function StopCard({
</div>
)}
<div className="text-[11.5px] mt-1" style={{ color: 'var(--gd-text-3)' }}>
{stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : 'Stop')}
{stop.cuisine || (stop.type === 'supercharger' ? 'Supercharger' : stop.type === 'destination-charger' ? 'Destination charger' : stop.type === 'hotel' ? 'Hotel' : stop.type === 'attraction' ? 'Attraction' : stop.type === 'restaurant' ? 'Restaurant' : stop.type === 'cafe' ? 'Cafe' : stop.type === 'viewpoint' ? 'Viewpoint' : stop.type === 'tunnel' ? 'Eurotunnel · drive on/off' : stop.type === 'ferry' ? 'Ferry crossing' : stop.type === 'crossing' ? 'Sea crossing' : 'Stop')}
</div>
{!active && stop.description && (
@@ -1151,7 +1307,7 @@ function StopCard({
</div>
)}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} />}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} onPickCrossing={onPickCrossing} />}
</div>
</div>
</div>
@@ -1200,6 +1356,7 @@ function TopBar({
origin, destination, onOriginChange, onDestinationChange, onODCommit,
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
travelDates, onOpenDates, onUseMyLocation,
}: {
origin: string; destination: string;
onOriginChange: (v: string) => void;
@@ -1211,7 +1368,19 @@ function TopBar({
vehicle: Vehicle; onOpenVehiclePanel: (rect: DOMRect) => void;
grokStatus: { label?: string };
onOpenGpx: () => void;
travelDates: TravelDates;
onOpenDates: (rect: DOMRect) => void;
onUseMyLocation: () => void;
}) {
const [locating, setLocating] = React.useState(false);
const handleLocate = async () => {
setLocating(true);
try { await onUseMyLocation(); } finally { setLocating(false); }
};
const datesLabel = travelDates.outbound
? formatDateRange(travelDates.outbound, travelDates.return)
: 'Add dates';
const datesEmpty = !travelDates.outbound;
return (
<div
className="flex items-center gap-4 px-5 flex-shrink-0"
@@ -1249,6 +1418,20 @@ function TopBar({
className="bg-transparent border-none outline-none text-[13px] w-full"
style={{ color: 'var(--gd-text)' }}
/>
<button
onClick={handleLocate}
disabled={locating}
title="Use my current location"
className="grid place-items-center rounded-md flex-shrink-0 disabled:opacity-40"
style={{
width: 26, height: 26,
background: 'var(--gd-panel-2)',
border: '1px solid var(--gd-border)',
color: 'var(--gd-text-2)',
}}
>
<Crosshair className={`w-3.5 h-3.5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
<div className="px-3.5 flex items-center gap-2 h-full flex-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-red)' }} />
@@ -1326,6 +1509,20 @@ function TopBar({
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
</button>
<button
onClick={(e) => onOpenDates(e.currentTarget.getBoundingClientRect())}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{
background: datesEmpty ? 'var(--gd-red-soft)' : 'var(--gd-panel)',
border: `1px solid ${datesEmpty ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text)',
}}
>
<CalendarDays className="w-3.5 h-3.5" />
<div className="text-[12px] font-medium">{datesLabel}</div>
<ChevronDown className="w-3 h-3" style={{ color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text-3)' }} />
</button>
<ChipButton onClick={() => onOpenGpx()}>
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Export
@@ -1362,6 +1559,9 @@ export default function TeslaTripPlanner() {
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [travelDates, setTravelDates] = useState<TravelDates>({ outbound: null, return: null, travellers: 2 });
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
@@ -1436,6 +1636,21 @@ export default function TeslaTripPlanner() {
lastPrefetchKey.current = prefetchKey;
}, [prefetchKey, selectedVariant]);
// Drop variant cache + prefetch ledger when travel dates change — pricing is stale.
const lastDatesKey = React.useRef<string>('');
React.useEffect(() => {
const key = `${travelDates.outbound || ''}|${travelDates.return || ''}|${travelDates.travellers || ''}`;
if (lastDatesKey.current && lastDatesKey.current !== key) {
setVariantCache(prev => {
const next: typeof prev = {};
if (prev[selectedVariant]) next[selectedVariant] = prev[selectedVariant];
return next;
});
prefetchedRef.current.clear();
}
lastDatesKey.current = key;
}, [travelDates.outbound, travelDates.return, travelDates.travellers, selectedVariant]);
React.useEffect(() => {
if (!prefetchKey) return;
if (thinking || variantSwitching) return;
@@ -1506,6 +1721,8 @@ export default function TeslaTripPlanner() {
selectedVariant: variantToUse,
origin: origin.trim() || undefined,
destination: destination.trim() || undefined,
travelDates: (travelDates.outbound || travelDates.return || travelDates.travellers)
? travelDates : undefined,
}),
});
if (!response.ok || !response.body) throw new Error('Failed to get streaming response');
@@ -1650,6 +1867,16 @@ export default function TeslaTripPlanner() {
toast.success(`Added ${place.name} to your trip`);
};
const moveStop = (stopId: string, direction: -1 | 1) => {
// Move within the flat list across day boundaries — nudges by one slot.
const flat = allStops;
const idx = flat.findIndex(s => s.id === stopId);
if (idx < 0) return;
const targetIdx = idx + direction;
if (targetIdx < 0 || targetIdx >= flat.length) return;
reorderStops(stopId, flat[targetIdx].id);
};
const reorderStops = (dragId: string, targetId: string) => {
if (dragId === targetId) return;
const next = structuredClone(itinerary);
@@ -1681,6 +1908,27 @@ export default function TeslaTripPlanner() {
toast.info('Stop reordered');
};
const useMyLocation = async () => {
const t = toast.loading('Locating your car…');
const coords = await getBrowserLocation();
if (!coords) {
toast.error('Could not get your location', {
id: t,
description: "Tesla's browser may not expose GPS — type your postcode instead.",
});
return;
}
const name = await reverseGeocode(coords.latitude, coords.longitude);
if (!name) {
const fallback = `${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}`;
setOrigin(fallback);
toast.success('Got your location', { id: t, description: fallback });
return;
}
setOrigin(name);
toast.success('Origin set to your location', { id: t, description: name });
};
const handleODCommit = () => {
const from = origin.trim();
const to = destination.trim();
@@ -1761,6 +2009,26 @@ export default function TeslaTripPlanner() {
toast.success(`Swapped to ${alt.name}`);
};
const pickCrossing = (stopId: string, crossing: CrossingOption) => {
const next = structuredClone(itinerary);
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === stopId);
if (idx === -1) continue;
const stop = d.stops[idx];
if (!stop.crossingOptions) continue;
stop.crossingOptions = stop.crossingOptions.map(o => ({ ...o, isCurrent: o.id === crossing.id }));
stop.name = `${crossing.operator} · ${crossing.fromPort}${crossing.toPort}`;
stop.durationMin = crossing.durationMin;
stop.type = crossing.mode === 'tunnel' ? 'tunnel' : 'ferry';
}
setItinerary(next);
toast.success(`Switched crossing to ${crossing.operator}`, {
description: `${crossing.fromPort}${crossing.toPort} · ${formatDuration(crossing.durationMin)}`,
});
// Ask Grok to re-plan the road portions either side of the new crossing
sendMessage(`I picked the ${crossing.operator} crossing (${crossing.fromPort}${crossing.toPort}). Re-plan the stops on either side to match.`, { silent: true });
};
const activeStop = activeStopId ? allStops.find(s => s.id === activeStopId) || null : null;
const dateLabels = ['Today', 'Tomorrow'];
@@ -1778,6 +2046,9 @@ export default function TeslaTripPlanner() {
onOpenVehiclePanel={(rect) => { setVehicleAnchor(rect); setVehiclePanelOpen(true); }}
grokStatus={grokStatus}
onOpenGpx={() => setModal({ kind: 'gpx' })}
travelDates={travelDates}
onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }}
onUseMyLocation={useMyLocation}
/>
{variants.length > 0 && (
@@ -2001,6 +2272,24 @@ export default function TeslaTripPlanner() {
</button>
</div>
{/* Travel-dates nudge */}
{itinerary.needsTravelDates && !travelDates.outbound && allStops.length > 0 && (
<button
onClick={(e) => { setDateAnchor(e.currentTarget.getBoundingClientRect()); setDatePickerOpen(true); }}
className="mx-4 mt-3 px-3 py-2 rounded-lg text-left flex items-center gap-2.5 transition hover:bg-white/[0.02]"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)' }}
>
<CalendarDays 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)' }}>Add travel dates for accurate pricing</div>
<div className="text-[10.5px]" style={{ color: 'var(--gd-text-3)' }}>
Ferry, Eurotunnel and hotel prices vary by date — currently showing off-peak ballpark.
</div>
</div>
<ChevronDown className="w-3 h-3 rotate-[-90deg]" style={{ color: 'var(--gd-red)' }} />
</button>
)}
{/* Stops list */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{itinerary.days.length === 0 ? (
@@ -2060,6 +2349,11 @@ export default function TeslaTripPlanner() {
onSwap={(alt) => swapStop(stop.id, alt)}
onRemove={() => removeStop(stop.id)}
onCustomise={() => setModal({ kind: 'customise', stopId: stop.id })}
onPickCrossing={(c) => pickCrossing(stop.id, c)}
onMoveUp={() => moveStop(stop.id, -1)}
onMoveDown={() => moveStop(stop.id, 1)}
canMoveUp={allStops[0]?.id !== stop.id}
canMoveDown={allStops[allStops.length - 1]?.id !== stop.id}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', stop.id);
e.dataTransfer.effectAllowed = 'move';
@@ -2173,6 +2467,20 @@ export default function TeslaTripPlanner() {
onSelect={setVehicle}
onClose={() => setVehiclePanelOpen(false)}
/>
<TravelDatesPanel
open={datePickerOpen}
anchorRect={dateAnchor}
value={travelDates}
onChange={setTravelDates}
onClose={() => setDatePickerOpen(false)}
onApply={() => {
setDatePickerOpen(false);
if (allStops.length > 0) {
sendMessage(`Update the itinerary for travel dates ${formatDateRange(travelDates.outbound, travelDates.return)}`, { silent: true });
}
}}
/>
</ErrorBoundary>
);
}
@@ -2931,6 +3239,218 @@ function GpxExportModal({ itinerary, onClose }: { itinerary: Itinerary; onClose:
);
}
// ─── Travel dates panel ──────────────────────────────────────────────────────
function TravelDatesPanel({
open, anchorRect, value, onChange, onClose, onApply,
}: {
open: boolean;
anchorRect: DOMRect | null;
value: TravelDates;
onChange: (v: TravelDates) => void;
onClose: () => void;
onApply: () => void;
}) {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!open || !anchorRect) return null;
const panelWidth = 380;
const left = Math.max(12, Math.min(window.innerWidth - panelWidth - 12, anchorRect.right - panelWidth));
const top = anchorRect.bottom + 8;
const nights = nightsBetween(value.outbound, value.return);
const today = new Date().toISOString().slice(0, 10);
return (
<>
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={onClose} />
<div
className="fixed overflow-hidden"
style={{
zIndex: 9999,
left, top, width: panelWidth,
background: 'rgba(20,20,24,0.96)',
backdropFilter: 'blur(18px)',
border: '1px solid var(--gd-border-2)',
borderRadius: 14,
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
<div className="px-4 py-3 flex items-center justify-between" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div>
<div className="text-[13px] font-medium">Travel dates</div>
<div className="text-[11px]" style={{ color: 'var(--gd-text-3)' }}>
Sharpens crossing, ferry and hotel pricing
</div>
</div>
{nights != null && (
<div className="text-[11px] num px-2 py-0.5 rounded-full" style={{ background: 'var(--gd-red-soft)', color: 'var(--gd-red)' }}>
{nights} night{nights === 1 ? '' : 's'}
</div>
)}
</div>
<div className="px-4 py-3 space-y-3">
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5" style={{ color: 'var(--gd-text-3)' }}>Outbound</div>
<input
type="date"
min={today}
value={value.outbound || ''}
onChange={(e) => onChange({ ...value, outbound: e.target.value || null })}
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
/>
</div>
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
<span>Return</span>
{value.return && (
<button className="text-[10.5px] hover:underline" onClick={() => onChange({ ...value, return: null })}>
Make one-way
</button>
)}
</div>
<input
type="date"
min={value.outbound || today}
value={value.return || ''}
onChange={(e) => onChange({ ...value, return: e.target.value || null })}
className="w-full text-[13px] px-3 py-2 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)', colorScheme: 'dark' }}
/>
</div>
<div>
<div className="text-[10.5px] uppercase tracking-wider mb-1.5 flex items-center justify-between" style={{ color: 'var(--gd-text-3)' }}>
<span>Travellers</span>
<Users className="w-3 h-3" />
</div>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map(n => {
const selected = (value.travellers ?? 2) === n;
return (
<button
key={n}
onClick={() => onChange({ ...value, travellers: n })}
className="flex-1 h-9 rounded-lg text-[12.5px] num transition"
style={{
background: selected ? 'var(--gd-red)' : 'var(--gd-panel)',
border: `1px solid ${selected ? 'var(--gd-red)' : 'var(--gd-border)'}`,
color: selected ? '#fff' : 'var(--gd-text-2)',
}}
>
{n}
</button>
);
})}
</div>
</div>
</div>
<div className="px-4 py-3 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={() => { onChange({ outbound: null, return: null, travellers: value.travellers }); }}
className="flex-1 h-9 text-[12px] rounded-lg"
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Clear
</button>
<button
onClick={onApply}
disabled={!value.outbound}
className="flex-[2] h-9 text-[12px] rounded-lg disabled:opacity-40"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
{value.outbound ? 'Apply & re-quote' : 'Pick an outbound date'}
</button>
</div>
</div>
</>
);
}
// ─── Crossing swap block ─────────────────────────────────────────────────────
function CrossingSwapBlock({ options, onPick }: { options: CrossingOption[]; onPick: (c: CrossingOption) => void }) {
if (options.length === 0) return null;
const current = options.find(o => o.isCurrent) || options[0];
return (
<div className="mt-2 rounded-xl overflow-hidden" style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<Ship className="w-3.5 h-3.5" style={{ color: 'var(--gd-blue)' }} />
<div className="text-[10.5px] uppercase tracking-wider" style={{ color: 'var(--gd-text-3)' }}>
Pick your crossing — {options.length} option{options.length === 1 ? '' : 's'}
</div>
</div>
<div className="divide-y" style={{ borderColor: 'var(--gd-border)' }}>
{options.map(opt => {
const isPicked = opt.id === current.id;
const deltaMin = formatDelta(opt.detourMin, 'min');
const deltaKm = formatDelta(opt.detourKm, 'km');
return (
<button
key={opt.id}
onClick={() => !isPicked && onPick(opt)}
disabled={isPicked}
className="w-full text-left px-3 py-2.5 transition hover:bg-white/[0.03] disabled:cursor-default"
style={{
background: isPicked ? 'var(--gd-blue-soft, rgba(80,140,255,0.08))' : 'transparent',
borderTop: 'none',
borderBottom: '1px solid var(--gd-border)',
}}
>
<div className="flex items-center gap-2 mb-1">
<div className="text-[12.5px] font-medium flex-1" style={{ color: 'var(--gd-text)' }}>
{opt.operator}
</div>
{opt.badge && (
<span
className="text-[9.5px] font-semibold tracking-wide uppercase px-1.5 py-0.5 rounded"
style={{
background: isPicked ? 'var(--gd-blue, #3b82f6)' : 'var(--gd-panel-2)',
color: isPicked ? '#fff' : 'var(--gd-text-2)',
}}
>
{opt.badge}
</span>
)}
{isPicked && (
<span className="text-[9.5px] font-semibold uppercase px-1.5 py-0.5 rounded" style={{ background: 'var(--gd-blue, #3b82f6)', color: '#fff' }}>
Current
</span>
)}
</div>
<div className="text-[11px] mb-1.5" style={{ color: 'var(--gd-text-2)' }}>
{opt.fromPort} → {opt.toPort}
</div>
<div className="flex items-center gap-3 text-[11px] num" style={{ color: 'var(--gd-text-3)' }}>
<span><Clock className="w-3 h-3 inline mr-0.5" />{formatDuration(opt.durationMin)}</span>
<span><Euro className="w-3 h-3 inline mr-0.5" />€{Math.round(opt.priceEur)}</span>
{opt.frequency && <span className="truncate">· {opt.frequency}</span>}
</div>
{(deltaMin || deltaKm) && !isPicked && (
<div className="mt-1 text-[10.5px] num" style={{ color: 'var(--gd-text-3)' }}>
Trip impact: {deltaMin || '±0 min'} · {deltaKm || '±0 km'}
</div>
)}
{opt.bookingUrl && (
<a
href={opt.bookingUrl} target="_blank" rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="mt-1 inline-flex items-center gap-1 text-[10.5px] hover:underline"
style={{ color: 'var(--gd-blue, #5b8bff)' }}
>
Book <ExternalLink className="w-2.5 h-2.5" />
</a>
)}
</button>
);
})}
</div>
</div>
);
}
// ─── Vehicle selector panel ──────────────────────────────────────────────────
function VehicleSelectorPanel({
open, anchorRect, selected, onSelect, onClose,
+69
View File
@@ -79,3 +79,72 @@ html, body, #root {
font-size: 12px;
line-height: 1.5;
}
/* ─── In-car browser overrides ─────────────────────────────────────────────
* Triggered when <html class="incar"> is set by client/src/lib/incar.ts.
* Goals: bigger fonts, larger tap targets, no expensive blurs (MCU1/MCU2
* fall off a cliff with backdrop-filter), no accidental hover-only states.
* Scoped to .incar so it never affects desktop builds.
*/
html.incar {
font-size: 17px; /* baseline bump — most leaf text uses px values
so this primarily affects rem-based things */
}
html.incar body {
/* Slightly thicker base text colour for readability at arm's length */
color: var(--gd-text);
}
/* Kill backdrop-filter entirely — it murders frame rate on MCU2 */
html.incar *,
html.incar *::before,
html.incar *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Bump every interactive control to 44px minimum tap target. */
html.incar button,
html.incar [role="button"],
html.incar input[type="date"],
html.incar input[type="text"],
html.incar input[type="number"] {
min-height: 44px;
font-size: 15px;
}
/* Scale tiny utility text up — Tailwind ships these as fixed px values so
* we override them globally inside .incar. */
html.incar .text-\[10px\],
html.incar .text-\[10\.5px\],
html.incar .text-\[11px\] { font-size: 13px !important; }
html.incar .text-\[11\.5px\],
html.incar .text-\[12px\] { font-size: 14px !important; }
html.incar .text-\[12\.5px\],
html.incar .text-\[13px\],
html.incar .text-\[13\.5px\] { font-size: 15px !important; }
html.incar .text-\[14px\] { font-size: 16px !important; }
html.incar .text-\[15px\] { font-size: 17px !important; }
html.incar .text-\[16px\] { font-size: 18px !important; }
html.incar .text-\[18px\] { font-size: 20px !important; }
html.incar .text-\[20px\] { font-size: 22px !important; }
/* Native date input is a tiny target on touch — fatten it. */
html.incar input[type="date"] {
padding: 12px 14px;
font-size: 16px;
}
/* Bigger scrollbars — finger-friendly */
html.incar ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
/* Hover states cause flicker on touch — disable on the incar build. */
html.incar .hover\:bg-white\/\[0\.04\]:hover,
html.incar .hover\:bg-white\/\[0\.03\]:hover,
html.incar .hover\:bg-white\/\[0\.025\]:hover,
html.incar .hover\:bg-white\/\[0\.02\]:hover {
background: transparent !important;
}