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:
@@ -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) ────────────────────────────────────────────────────
|
||||
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;
|
||||
active: boolean;
|
||||
hover: boolean;
|
||||
dragging: boolean;
|
||||
onSelect: () => void;
|
||||
onHover: (h: boolean) => void;
|
||||
onSwap: (alt: AlternativeStop) => 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 Icon = meta.icon;
|
||||
@@ -878,11 +922,17 @@ function StopCard({ stop, active, hover, onSelect, onHover, onSwap, onRemove }:
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
className="rounded-[12px] p-3.5 mb-2 cursor-pointer transition-all"
|
||||
style={{
|
||||
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)'}`,
|
||||
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">
|
||||
@@ -977,13 +1027,14 @@ function makePinIcon(color: string, active: boolean, hover: boolean): L.DivIcon
|
||||
|
||||
// ─── Top bar ─────────────────────────────────────────────────────────────────
|
||||
function TopBar({
|
||||
origin, destination, onOriginChange, onDestinationChange,
|
||||
origin, destination, onOriginChange, onDestinationChange, onODCommit,
|
||||
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
|
||||
vehicle, onVehicleChange, grokStatus,
|
||||
}: {
|
||||
origin: string; destination: string;
|
||||
onOriginChange: (v: string) => void;
|
||||
onDestinationChange: (v: string) => void;
|
||||
onODCommit: () => void;
|
||||
chatInput: string; setChatInput: (v: string) => void;
|
||||
onChatSubmit: () => void;
|
||||
chips: string[]; onRemoveChip: (i: number) => void;
|
||||
@@ -1021,6 +1072,8 @@ function TopBar({
|
||||
<input
|
||||
value={origin}
|
||||
onChange={(e) => onOriginChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }}
|
||||
onBlur={onODCommit}
|
||||
placeholder="From"
|
||||
className="bg-transparent border-none outline-none text-[13px] w-full"
|
||||
style={{ color: 'var(--gd-text)' }}
|
||||
@@ -1031,6 +1084,8 @@ function TopBar({
|
||||
<input
|
||||
value={destination}
|
||||
onChange={(e) => onDestinationChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); onODCommit(); } }}
|
||||
onBlur={onODCommit}
|
||||
placeholder="To"
|
||||
className="bg-transparent border-none outline-none text-[13px] w-full"
|
||||
style={{ color: 'var(--gd-text)' }}
|
||||
@@ -1139,9 +1194,11 @@ export default function TeslaTripPlanner() {
|
||||
const [hoverStopId, setHoverStopId] = useState<string | null>(null);
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
|
||||
const [variants, setVariants] = useState<RouteVariant[]>([]);
|
||||
const [selectedVariant, setSelectedVariant] = useState<RouteVariant['id']>('fast');
|
||||
const [variantSwitching, setVariantSwitching] = useState(false);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
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']) => {
|
||||
if (variantId === selectedVariant || variantSwitching || thinking) return;
|
||||
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
||||
@@ -1304,6 +1407,7 @@ export default function TeslaTripPlanner() {
|
||||
<TopBar
|
||||
origin={origin} destination={destination}
|
||||
onOriginChange={setOrigin} onDestinationChange={setDestination}
|
||||
onODCommit={handleODCommit}
|
||||
chatInput={chatInput} setChatInput={setChatInput}
|
||||
onChatSubmit={() => sendMessage(chatInput)}
|
||||
chips={chips} onRemoveChip={(i) => setChips(chips.filter((_, idx) => idx !== i))}
|
||||
@@ -1491,18 +1595,40 @@ export default function TeslaTripPlanner() {
|
||||
{stops.map((stop, si) => {
|
||||
const isLast = si === stops.length - 1;
|
||||
const leg = legByFromId.get(stop.id);
|
||||
const hasNextDay = di < itinerary.days.length - 1;
|
||||
const showNightBlock = isLast && hasNextDay && (stop.type === 'hotel' || stop.type === 'supercharger');
|
||||
return (
|
||||
<div key={stop.id}>
|
||||
<StopCard
|
||||
stop={stop}
|
||||
active={stop.id === activeStopId}
|
||||
hover={stop.id === hoverStopId}
|
||||
dragging={stop.id === draggingId}
|
||||
onSelect={() => setActiveStopId(stop.id === activeStopId ? null : stop.id)}
|
||||
onHover={(h) => setHoverStopId(h ? stop.id : null)}
|
||||
onSwap={(alt) => swapStop(stop.id, alt)}
|
||||
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} />}
|
||||
{showNightBlock && (
|
||||
<NightBlock
|
||||
lastStop={stop}
|
||||
onOpenHotelOptions={() => setActiveStopId(stop.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user