feat: wire build/test infra, trips API, and enriched journey stops

- Add tsconfig.json (server) + client/tsconfig.{json,app.json,node.json}
  so typecheck and tsc -b actually work.
- Fix npm test to run Playwright (was running vitest on Playwright specs);
  typecheck now covers both server and client.
- Mount routes before app.listen, add error handler, mount optional
  @tonycodes/auth-express middleware when AUTH_SECRET is set.
- Add /api/trips (GET/POST/PATCH/DELETE) backed by an in-memory store
  that gracefully degrades when DATABASE_URL is unset.
- Add prisma/seed.ts skeleton and server/types/express.d.ts for req.auth.
- Rewrite Grok prompt for combo-aware planning: charge+eat,
  stay+destination-charging, eat+viewpoint, etc., with amenities,
  cuisine, priceLevel, duration, day titles and trip highlights.
- Extend Stop schema + normalization to preserve all enrichment fields.
- New StopCard component renders combo pill, description, meta row
  (charge / stop / battery / cuisine / £-level) and amenity icons;
  map popups show the same enriched detail; timeline gains day titles
  and a HIGHLIGHTS sidebar.
- Fix server TS errors (vehicle accepted as string | {name,rangeKm},
  JSON parse results typed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 10:32:53 +01:00
parent d516e93323
commit 89b24d4c34
24 changed files with 1263 additions and 243 deletions
+185 -31
View File
@@ -20,27 +20,65 @@ const VEHICLES = [
{ name: 'Model Y RWD (EU)', rangeKm: 455, efficiency: 158 },
];
type StopType = 'supercharger' | 'destination-charger' | 'hotel' | 'attraction' | 'restaurant' | 'cafe' | 'viewpoint' | 'custom';
interface Stop {
id: string;
name: string;
type: 'supercharger' | 'hotel' | 'attraction' | 'restaurant' | 'custom';
type: StopType;
lat: number;
lng: number;
day: number;
order: number;
estArrivalBattery?: number;
chargeMinutes?: number;
durationMin?: number;
combo?: string | null;
description?: string;
amenities?: string[];
cuisine?: string | null;
priceLevel?: number;
notes?: string;
}
interface Itinerary {
days: { day: number; stops: Stop[] }[];
summary: { totalDistanceKm: number; estDriveHours: number; estChargeHours: number; superchargers: number; hotels: number };
days: { day: number; title?: string; stops: Stop[] }[];
summary: {
totalDistanceKm: number;
estDriveHours: number;
estChargeHours: number;
superchargers: number;
hotels: number;
highlights?: string[];
};
}
const EMPTY_ITINERARY: Itinerary = {
days: [],
summary: { totalDistanceKm: 0, estDriveHours: 0, estChargeHours: 0, superchargers: 0, hotels: 0 },
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'];
const AMENITY_ICONS: Record<string, string> = {
restaurant: '🍽️',
cafe: '☕',
'fast-food': '🍔',
supermarket: '🛒',
toilets: '🚻',
shopping: '🛍️',
wifi: '📶',
playground: '🧒',
'ev-charging': '⚡',
'destination-charging': '🔌',
hotel: '🛏️',
coffee: '☕',
viewpoint: '🌄',
museum: '🏛️',
park: '🌳',
beach: '🏖️',
gym: '🏋️',
pool: '🏊',
};
const QUICK_PROMPTS = [
@@ -102,26 +140,44 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
if (geo) { lat = geo.lat; lng = geo.lng; }
}
const sharedFields = {
estArrivalBattery: s.estArrivalBattery,
chargeMinutes: s.chargeMinutes,
durationMin: s.durationMin,
combo: s.combo ?? null,
description: typeof s.description === 'string' ? s.description : undefined,
amenities: Array.isArray(s.amenities) ? s.amenities.filter((a: unknown) => typeof a === 'string') : undefined,
cuisine: typeof s.cuisine === 'string' ? s.cuisine : null,
priceLevel: typeof s.priceLevel === 'number' ? s.priceLevel : undefined,
notes: s.notes,
};
const resolvedType: StopType = STOP_TYPES.includes(s.type) ? s.type : 'custom';
if (lat === null || lng === null) {
validStops.push({
id: s.id || `text-${Date.now()}-${Math.random()}`,
name, type: s.type || 'custom', lat: null, lng: null,
name, type: resolvedType, lat: null, lng: null,
day: day.day || 1, order: s.order || validStops.length + 1,
estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, notes: s.notes,
...sharedFields,
});
continue;
}
validStops.push({
id: s.id || `stop-${Date.now()}-${Math.random()}`,
name, type: ['supercharger','hotel','attraction','restaurant','custom'].includes(s.type) ? s.type : 'custom',
name, type: resolvedType,
lat, lng, day: day.day || 1, order: s.order || validStops.length + 1,
estArrivalBattery: s.estArrivalBattery, chargeMinutes: s.chargeMinutes, notes: s.notes,
...sharedFields,
});
}
if (validStops.length > 0) {
normalizedDays.push({ day: day.day || normalizedDays.length + 1, stops: validStops.sort((a,b) => a.order - b.order) });
normalizedDays.push({
day: day.day || normalizedDays.length + 1,
title: typeof day.title === 'string' ? day.title : undefined,
stops: validStops.sort((a,b) => a.order - b.order),
});
}
}
@@ -134,8 +190,11 @@ async function normalizeAndSanitizeItinerary(raw: any): Promise<Itinerary> {
totalDistanceKm: raw.summary?.totalDistanceKm ?? 0,
estDriveHours: raw.summary?.estDriveHours ?? 0,
estChargeHours: raw.summary?.estChargeHours ?? 0,
superchargers: allStops.filter(s => s.type === 'supercharger').length,
superchargers: allStops.filter(s => s.type === 'supercharger' || s.type === 'destination-charger').length,
hotels: allStops.filter(s => s.type === 'hotel').length,
highlights: Array.isArray(raw.summary?.highlights)
? raw.summary.highlights.filter((h: unknown) => typeof h === 'string')
: [],
},
};
}
@@ -176,6 +235,61 @@ async function getRoadRoute(from: Stop, to: Stop): Promise<[number, number][]> {
return [[from.lat, from.lng], [to.lat, to.lng]];
}
const STOP_DOT: Record<StopType, string> = {
supercharger: 'bg-[#E82127]',
'destination-charger': 'bg-rose-400',
hotel: 'bg-blue-500',
attraction: 'bg-amber-400',
restaurant: 'bg-emerald-400',
cafe: 'bg-amber-200',
viewpoint: 'bg-purple-400',
custom: 'bg-white/60',
};
function StopCard({ stop, onRemove }: { stop: Stop; onRemove: () => void }) {
const amenities = (stop.amenities || []).slice(0, 6);
return (
<div className="group bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm">
<div className="flex justify-between items-start gap-2">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className={`w-2.5 h-2.5 mt-1.5 rounded-full flex-shrink-0 ${STOP_DOT[stop.type] || 'bg-white/60'}`} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-tight truncate">{stop.name}</div>
{stop.combo && (
<div className="inline-block mt-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-[#E82127]/15 text-[#E82127] uppercase tracking-wider">
{stop.combo}
</div>
)}
{stop.description && (
<div className="text-xs text-white/60 mt-1 leading-snug line-clamp-2">{stop.description}</div>
)}
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-[11px] text-white/50">
{typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && <span> {stop.chargeMinutes}m charge</span>}
{typeof stop.durationMin === 'number' && stop.durationMin > 0 && <span> {stop.durationMin}m stop</span>}
{typeof stop.estArrivalBattery === 'number' && <span>🔋 {stop.estArrivalBattery}%</span>}
{stop.cuisine && <span className="truncate max-w-[120px]">🍽 {stop.cuisine}</span>}
{typeof stop.priceLevel === 'number' && <span>{'£'.repeat(Math.min(4, Math.max(1, stop.priceLevel)))}</span>}
</div>
{amenities.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{amenities.map(a => (
<span key={a} title={a} className="text-sm leading-none">{AMENITY_ICONS[a] || '•'}</span>
))}
</div>
)}
{typeof stop.lat !== 'number' && (
<div className="text-[11px] text-amber-400 mt-1">Location not yet on map</div>
)}
</div>
</div>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg>
</button>
</div>
</div>
);
}
export default function TeslaTripPlanner() {
const [messages, setMessages] = useState<any[]>([
{ id: 1, role: 'assistant', content: "Hello! I'm Grok Drive. I'm here to help you plan amazing Tesla road trips across the UK and Europe. Where would you like to go?" },
@@ -184,8 +298,14 @@ export default function TeslaTripPlanner() {
const [thinking, setThinking] = useState(false);
const [itinerary, setItinerary] = useState<Itinerary>(EMPTY_ITINERARY);
const [vehicle, setVehicle] = useState(VEHICLES[0]);
const [grokStatus, setGrokStatus] = useState({ provider: "local", label: "Local Heavy", detail: "", isLocal: true, model: "Heavy" });
const [roadRoutes, setRoadRoutes] = useState<[number, number][][]>([]);
// Fetch Grok provider status for the badge
React.useEffect(() => {
fetch("/api/grok/status").then(r => r.json()).then(setGrokStatus).catch(() => {});
}, []);
// Clean stops for map
const allStops: Stop[] = itinerary.days.flatMap(d => d.stops).filter((s): s is Stop => s != null && typeof s.lat === 'number');
@@ -195,19 +315,27 @@ export default function TeslaTripPlanner() {
return [[prev.lat, prev.lng], [stop.lat, stop.lng]] as [number, number][];
});
// When itinerary changes, fetch real road routes
// When itinerary changes, fetch real road routes using OSRM
React.useEffect(() => {
const fetchRoutes = async () => {
if (allStops.length < 2) {
const stops = itinerary.days
.flatMap(d => d.stops)
.filter((s): s is Stop => s != null && typeof s.lat === 'number');
if (stops.length < 2) {
setRoadRoutes([]);
return;
}
console.log('[TeslaTrip] Planning real driving routes between', stops.length, 'stops...');
const routes: [number, number][][] = [];
for (let i = 0; i < allStops.length - 1; i++) {
const route = await getRoadRoute(allStops[i], allStops[i + 1]);
for (let i = 0; i < stops.length - 1; i++) {
const route = await getRoadRoute(stops[i], stops[i + 1]);
routes.push(route);
}
setRoadRoutes(routes);
console.log('[TeslaTrip] Route planning complete. Polylines updated on map.');
};
fetchRoutes();
}, [itinerary]);
@@ -287,6 +415,7 @@ export default function TeslaTripPlanner() {
<div className="w-9 h-9 rounded-full bg-[#E82127] flex items-center justify-center"><Zap className="w-5 h-5" /></div>
<div>
<div className="font-semibold text-lg tracking-tight">Grok Drive</div>
<div className="ml-3 text-[10px] font-medium px-2.5 py-0.5 rounded-full border bg-emerald-500/10 text-emerald-400 border-emerald-500/30">Local Heavy</div>
<div className="text-xs text-white/50">UK &amp; Europe Headless Grok</div>
</div>
</div>
@@ -365,7 +494,30 @@ export default function TeslaTripPlanner() {
<TileLayer attribution='&copy; OpenStreetMap contributors' url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" />
{allStops.map(stop => (
<Marker key={stop.id} position={[stop.lat, stop.lng]}>
<Popup><strong>{stop.name}</strong><br />{stop.type === 'supercharger' && `${stop.chargeMinutes} min charge`}{stop.type === 'hotel' && '🏨 Destination charging available'}</Popup>
<Popup>
<div className="text-[13px] leading-snug">
<div className="font-semibold text-sm mb-0.5">{stop.name}</div>
{stop.combo && (
<div className="text-[11px] font-medium text-[#E82127] uppercase tracking-wider mb-1">{stop.combo}</div>
)}
{stop.description && <div className="mb-1.5">{stop.description}</div>}
<div className="flex flex-wrap gap-1.5 text-xs text-slate-700">
{typeof stop.chargeMinutes === 'number' && stop.chargeMinutes > 0 && <span> {stop.chargeMinutes} min charge</span>}
{typeof stop.durationMin === 'number' && stop.durationMin > 0 && <span> {stop.durationMin} min stop</span>}
{typeof stop.estArrivalBattery === 'number' && <span>🔋 arrive at {stop.estArrivalBattery}%</span>}
{stop.cuisine && <span>🍽 {stop.cuisine}</span>}
{typeof stop.priceLevel === 'number' && <span>{'£'.repeat(Math.min(4, Math.max(1, stop.priceLevel)))}</span>}
</div>
{stop.amenities && stop.amenities.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{stop.amenities.slice(0, 8).map(a => (
<span key={a} title={a} className="text-base leading-none">{AMENITY_ICONS[a] || '•'}</span>
))}
</div>
)}
{stop.notes && <div className="mt-1.5 text-[11px] text-slate-500 italic">{stop.notes}</div>}
</div>
</Popup>
</Marker>
))}
{displayPolylines.map((positions, idx) => (
@@ -384,36 +536,38 @@ export default function TeslaTripPlanner() {
</div>
</div>
<div className="h-[215px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
<div className="h-[280px] border-t border-white/10 bg-[#111111] p-4 overflow-x-auto">
{itinerary.days.length > 0 ? (
<div className="flex gap-6 min-w-max">
{itinerary.days.map((day, di) => {
const validStops = (day.stops || []).filter((s): s is Stop => s != null);
return (
<div key={di} className="w-[310px]">
<div className="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">DAY {day.day}</div>
<div key={di} className="w-[340px]">
<div className="flex items-baseline gap-2 mb-2">
<div className="uppercase text-xs tracking-[2px] text-[#E82127]">DAY {day.day}</div>
{day.title && <div className="text-xs text-white/60 truncate">{day.title}</div>}
</div>
{validStops.length > 0 ? validStops.sort((a,b) => a.order - b.order).map(stop => (
<div key={stop.id} className="group flex justify-between items-center bg-[#1a1f2b] hover:bg-[#22283a] border border-white/5 rounded-2xl px-4 py-2.5 mb-1.5 text-sm">
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${stop.type === 'supercharger' ? 'bg-[#E82127]' : stop.type === 'hotel' ? 'bg-blue-500' : 'bg-white/60'}`} />
<div>
<div className="font-medium leading-tight">{stop.name}</div>
{typeof stop.lat === 'number' ? <div className="text-xs text-emerald-400">Placed on map</div> : <div className="text-xs text-amber-400">Location not yet on map</div>}
</div>
</div>
<button onClick={() => removeStop(stop.id)} className="opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.595 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.595-1.858L5 7m5 4v6m4-6v6m1-10V9a1 1 0 00-1-1h-4a1 1 0 00-1 1v1M9 7h6" /></svg>
</button>
</div>
<StopCard key={stop.id} stop={stop} onRemove={() => removeStop(stop.id)} />
)) : <div className="text-xs text-white/40 italic">No valid stops for this day</div>}
</div>
);
})}
{itinerary.summary.highlights && itinerary.summary.highlights.length > 0 && (
<div className="w-[260px] border-l border-white/5 pl-5">
<div className="uppercase text-xs tracking-[2px] text-[#E82127] mb-2">HIGHLIGHTS</div>
<ul className="space-y-1.5 text-xs text-white/80">
{itinerary.summary.highlights.map((h, i) => (
<li key={i} className="flex gap-2"><span className="text-[#E82127]"></span>{h}</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="text-white/60 mb-2">No trip planned yet</div>
<div className="text-sm text-white/40 max-w-xs">Describe your journey in the chat and Ill create the perfect route with Superchargers and stops.</div>
<div className="text-sm text-white/40 max-w-xs">Describe your journey in the chat and Ill create the perfect route with Superchargers, hotels, food and combo stops.</div>
</div>
)}
</div>