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
+10 -4
View File
@@ -15,6 +15,11 @@ const ChatRequestSchema = z.object({
selectedVariant: z.enum(['fast', 'scenic', 'cheap']).optional(),
origin: z.string().optional(),
destination: z.string().optional(),
travelDates: z.object({
outbound: z.string().nullable().optional(),
return: z.string().nullable().optional(),
travellers: z.number().int().min(1).max(8).optional(),
}).optional(),
});
router.post('/chat', async (req, res) => {
@@ -30,7 +35,7 @@ router.post('/chat', async (req, res) => {
return res.status(400).json({ error: 'Invalid request' });
}
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
log.info({
requestId,
@@ -41,6 +46,7 @@ router.post('/chat', async (req, res) => {
selectedVariant,
origin,
destination,
travelDates,
}, 'Parsed chat request');
// Call Grok (this will produce very detailed logs inside GrokHeadlessClient)
@@ -49,7 +55,7 @@ router.post('/chat', async (req, res) => {
itinerary,
vehicle,
selectedVariant,
{ origin, destination },
{ origin, destination, travelDates },
);
const duration = Date.now() - start;
@@ -91,7 +97,7 @@ router.post('/chat/stream', async (req, res) => {
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.format() });
}
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination } = parsed.data;
const { message, vehicle, itinerary, history = [], selectedVariant = 'fast', origin, destination, travelDates } = parsed.data;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -124,7 +130,7 @@ router.post('/chat/stream', async (req, res) => {
itinerary,
vehicle,
selectedVariant,
{ origin, destination },
{ origin, destination, travelDates },
);
for await (const ev of stream) {
if (cancelled) break;