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) ────────────────────────────────────────────────────
|
// ─── 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user