feat(ui): Phase 3a — night block, drag-to-reorder, O/D replan

- NightBlock component: dashed-blue panel that appears under the last
  stop of any day (when it's a hotel or supercharger) and before the
  next day starts, showing "Sleep in car at services" or the hotel
  name with a "Hotel options" button that opens the stop's expansion
  to its overnight swap section.
- Drag-to-reorder: every stop card is draggable; dropping on another
  card moves the dragged stop into that position (and into that
  day if you drag across day boundaries). Order numbers are
  renormalised after each drop; the legs + map polylines + day totals
  recompute automatically.
- Origin/Destination editable replan: editing the origin or
  destination input and pressing Enter or blurring sends a "Replan
  the trip from {origin} to {destination}" chat message. Auto-
  populating from the current itinerary does not trigger a replan;
  only real user edits do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 13:53:32 +01:00
parent f63af36451
commit ab457dafe2
+128 -2
View File
@@ -857,15 +857,59 @@ function ExpStat({ label, value, tone = 'text' }: { label: string; value: string
); );
} }
// ─── Night transition block (between days) ───────────────────────────────────
function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHotelOptions: () => void }) {
const isHotel = lastStop.type === 'hotel';
const label = isHotel ? lastStop.name : 'Sleep in car at services';
const detail = isHotel
? `Overnight · ${lastStop.cuisine || 'destination charging'}`
: 'Safe overnight at last Supercharger · 24h facilities';
return (
<div
className="my-3.5 p-3.5 rounded-[12px] flex items-center gap-3"
style={{
background: 'rgba(96,165,250,0.07)',
border: '1px dashed rgba(96,165,250,0.4)',
}}
>
<div
className="w-[38px] h-[38px] rounded-[9px] grid place-items-center flex-shrink-0"
style={{ background: 'rgba(96,165,250,0.16)' }}
>
<Bed className="w-4 h-4" style={{ color: 'var(--gd-blue)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{label}</div>
<div className="text-[11.5px]" style={{ color: 'var(--gd-text-3)' }}>{detail}</div>
</div>
<button
onClick={onOpenHotelOptions}
className="h-7 px-2.5 inline-flex items-center gap-1.5 rounded-lg text-[11.5px] whitespace-nowrap"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
>
<ArrowLeftRight className="w-3 h-3" /> Hotel options
</button>
</div>
);
}
// ─── Stop card (icon-led) ──────────────────────────────────────────────────── // ─── Stop card (icon-led) ────────────────────────────────────────────────────
function StopCard({ stop, active, hover, onSelect, onHover, onSwap, onRemove }: { function StopCard({
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove,
onDragStart, onDragOver, onDrop, onDragEnd,
}: {
stop: Stop; stop: Stop;
active: boolean; active: boolean;
hover: boolean; hover: boolean;
dragging: boolean;
onSelect: () => void; onSelect: () => void;
onHover: (h: boolean) => void; onHover: (h: boolean) => void;
onSwap: (alt: AlternativeStop) => void; onSwap: (alt: AlternativeStop) => void;
onRemove: () => void; onRemove: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onDragEnd: () => void;
}) { }) {
const meta = stopMeta(stop.type); const meta = stopMeta(stop.type);
const Icon = meta.icon; const Icon = meta.icon;
@@ -878,11 +922,17 @@ function StopCard({ stop, active, hover, onSelect, onHover, onSwap, onRemove }:
onClick={onSelect} onClick={onSelect}
onMouseEnter={() => onHover(true)} onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)} onMouseLeave={() => onHover(false)}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className="rounded-[12px] p-3.5 mb-2 cursor-pointer transition-all" className="rounded-[12px] p-3.5 mb-2 cursor-pointer transition-all"
style={{ style={{
background: active ? 'var(--gd-panel-2)' : hover ? 'rgba(255,255,255,0.025)' : 'var(--gd-panel)', background: active ? 'var(--gd-panel-2)' : hover ? 'rgba(255,255,255,0.025)' : 'var(--gd-panel)',
border: `1px solid ${active ? 'var(--gd-red-line)' : hover ? 'var(--gd-border-2)' : 'var(--gd-border)'}`, border: `1px solid ${active ? 'var(--gd-red-line)' : hover ? 'var(--gd-border-2)' : 'var(--gd-border)'}`,
boxShadow: active ? '0 0 0 1px var(--gd-red-soft), 0 6px 20px rgba(0,0,0,0.25)' : 'none', boxShadow: active ? '0 0 0 1px var(--gd-red-soft), 0 6px 20px rgba(0,0,0,0.25)' : 'none',
opacity: dragging ? 0.5 : 1,
}} }}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -977,13 +1027,14 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon
// ─── Top bar ───────────────────────────────────────────────────────────────── // ─── Top bar ─────────────────────────────────────────────────────────────────
function TopBar({ function TopBar({
origin, destination, onOriginChange, onDestinationChange, origin, destination, onOriginChange, onDestinationChange, onODCommit,
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onVehicleChange, grokStatus, vehicle, onVehicleChange, grokStatus,
}: { }: {
origin: string; destination: string; origin: string; destination: string;
onOriginChange: (v: string) => void; onOriginChange: (v: string) => void;
onDestinationChange: (v: string) => void; onDestinationChange: (v: string) => void;
onODCommit: () => void;
chatInput: string; setChatInput: (v: string) => void; chatInput: string; setChatInput: (v: string) => void;
onChatSubmit: () => void; onChatSubmit: () => void;
chips: string[]; onRemoveChip: (i: number) => void; chips: string[]; onRemoveChip: (i: number) => void;
@@ -1021,6 +1072,8 @@ function TopBar({
<input <input
value={origin} value={origin}
onChange={(e) => onOriginChange(e.target.value)} onChange={(e) => onOriginChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }}
onBlur={onODCommit}
placeholder="From" placeholder="From"
className="bg-transparent border-none outline-none text-[13px] w-full" className="bg-transparent border-none outline-none text-[13px] w-full"
style={{ color: 'var(--gd-text)' }} style={{ color: 'var(--gd-text)' }}
@@ -1031,6 +1084,8 @@ function TopBar({
<input <input
value={destination} value={destination}
onChange={(e) => onDestinationChange(e.target.value)} onChange={(e) => onDestinationChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }}
onBlur={onODCommit}
placeholder="To" placeholder="To"
className="bg-transparent border-none outline-none text-[13px] w-full" className="bg-transparent border-none outline-none text-[13px] w-full"
style={{ color: 'var(--gd-text)' }} style={{ color: 'var(--gd-text)' }}
@@ -1139,9 +1194,11 @@ export default function TeslaTripPlanner() {
const [hoverStopId, setHoverStopId] = useState<string | null>(null); const [hoverStopId, setHoverStopId] = useState<string | null>(null);
const [origin, setOrigin] = useState(''); const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState(''); const [destination, setDestination] = useState('');
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
const [variants, setVariants] = useState<RouteVariant[]>([]); const [variants, setVariants] = useState<RouteVariant[]>([]);
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast'); const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
const [variantSwitching, setVariantSwitching] = useState(false); const [variantSwitching, setVariantSwitching] = useState(false);
const [draggingId, setDraggingId] = useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {}); fetch('/api/grok/status').then(r => r.json()).then(setGrokStatus).catch(() => {});
@@ -1243,6 +1300,52 @@ export default function TeslaTripPlanner() {
} }
}; };
const reorderStops = (dragId: string, targetId: string) => {
if (dragId === targetId) return;
const next = structuredClone(itinerary);
// Find the dragged stop across all days and remove it
let dragged: Stop | null = null;
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === dragId);
if (idx !== -1) {
dragged = d.stops.splice(idx, 1)[0] as Stop;
break;
}
}
if (!dragged) return;
// Insert before the target stop
for (const d of next.days) {
const idx = d.stops.findIndex(s => s.id === targetId);
if (idx !== -1) {
dragged.day = d.day;
d.stops.splice(idx, 0, dragged);
break;
}
}
// Re-normalise order numbers within each day
for (const d of next.days) {
d.stops.forEach((s, i) => { s.order = i + 1; });
}
next.days = next.days.filter(d => d.stops.length > 0);
setItinerary(next);
toast.info('Stop reordered');
};
const handleODCommit = () => {
const from = origin.trim();
const to = destination.trim();
if (!from || !to) return;
if (lastODSent.current && lastODSent.current.from === from && lastODSent.current.to === to) return;
// First time: only fire if user has actually edited (we auto-populate from itinerary)
const auto = allStops.length > 0 && from === allStops[0].name && to === allStops[allStops.length - 1].name;
if (auto && !lastODSent.current) {
lastODSent.current = { from, to };
return;
}
lastODSent.current = { from, to };
sendMessage(`Replan the trip from ${from} to ${to}`);
};
const switchVariant = (variantId: RouteVariant['id']) => { const switchVariant = (variantId: RouteVariant['id']) => {
if (variantId === selectedVariant || variantSwitching || thinking) return; if (variantId === selectedVariant || variantSwitching || thinking) return;
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user'); const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
@@ -1304,6 +1407,7 @@ export default function TeslaTripPlanner() {
<TopBar <TopBar
origin={origin} destination={destination} origin={origin} destination={destination}
onOriginChange={setOrigin} onDestinationChange={setDestination} onOriginChange={setOrigin} onDestinationChange={setDestination}
onODCommit={handleODCommit}
chatInput={chatInput} setChatInput={setChatInput} chatInput={chatInput} setChatInput={setChatInput}
onChatSubmit={() => sendMessage(chatInput)} onChatSubmit={() => sendMessage(chatInput)}
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))} chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
@@ -1491,18 +1595,40 @@ export default function TeslaTripPlanner() {
{stops.map((stop, si) => { {stops.map((stop, si) => {
const isLast = si === stops.length - 1; const isLast = si === stops.length - 1;
const leg = legByFromId.get(stop.id); const leg = legByFromId.get(stop.id);
const hasNextDay = di < itinerary.days.length - 1;
const showNightBlock = isLast && hasNextDay && (stop.type === 'hotel' || stop.type === 'supercharger');
return ( return (
<div key={stop.id}> <div key={stop.id}>
<StopCard <StopCard
stop={stop} stop={stop}
active={stop.id === activeStopId} active={stop.id === activeStopId}
hover={stop.id === hoverStopId} hover={stop.id === hoverStopId}
dragging={stop.id === draggingId}
onSelect={() => setActiveStopId(stop.id === activeStopId ? null : stop.id)} onSelect={() => setActiveStopId(stop.id === activeStopId ? null : stop.id)}
onHover={(h) => setHoverStopId(h ? stop.id : null)} onHover={(h) => setHoverStopId(h ? stop.id : null)}
onSwap={(alt) => swapStop(stop.id, alt)} onSwap={(alt) => swapStop(stop.id, alt)}
onRemove={() => removeStop(stop.id)} onRemove={() => removeStop(stop.id)}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', stop.id);
e.dataTransfer.effectAllowed = 'move';
setDraggingId(stop.id);
}}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => {
e.preventDefault();
const dragId = e.dataTransfer.getData('text/plain');
if (dragId && dragId !== stop.id) reorderStops(dragId, stop.id);
setDraggingId(null);
}}
onDragEnd={() => setDraggingId(null)}
/> />
{!isLast && <LegRow leg={leg} />} {!isLast && <LegRow leg={leg} />}
{showNightBlock && (
<NightBlock
lastStop={stop}
onOpenHotelOptions={() => setActiveStopId(stop.id)}
/>
)}
</div> </div>
); );
})} })}