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:
@@ -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 & Europe • Headless Grok</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,7 +494,30 @@ export default function TeslaTripPlanner() {
|
||||
<TileLayer attribution='© 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 I’ll 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 I’ll create the perfect route with Superchargers, hotels, food and combo stops.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user