Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/app/api/route-detour/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,10 @@ export async function POST(request: NextRequest) {
return { id: s.id, detourMin: -1 };
}

const detourSec = Math.max(0, detourDuration - baselineDuration);
const detourMin = Math.round(detourSec / 6) / 10;
const detourSec = detourDuration - baselineDuration;
// Large negative means baseline was longer than detour — bad baseline match
if (detourSec < -60) return { id: s.id, detourMin: -1 };
const detourMin = Math.round(Math.max(0, detourSec) / 6) / 10;

return { id: s.id, detourMin };
} catch (err) {
Expand Down
25 changes: 21 additions & 4 deletions src/components/home-client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FuelType, StationGeoJSON, StationsGeoJSONCollection } from "@/types/station";
import type { MapRef } from "react-map-gl/maplibre";
import type { Route } from "@/components/map/route-layer";
Expand Down Expand Up @@ -135,6 +135,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
if (!res.ok) {
if (abortRef.current === controller) {
if (!isStationLeg) setRouteState(null);
else setSelectedStationId(null);
setStationLegRoutes(null);
setRouteError("route.error");
}
Expand All @@ -144,6 +145,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
if (data.routes.length === 0) {
if (abortRef.current === controller) {
if (!isStationLeg) setRouteState(null);
else setSelectedStationId(null);
setStationLegRoutes(null);
setRouteError("route.noRoute");
}
Expand Down Expand Up @@ -178,6 +180,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
console.error("Route calculation failed:", err);
if (abortRef.current === controller) {
if (!isStationLeg) setRouteState(null);
else setSelectedStationId(null);
setStationLegRoutes(null);
setRouteError("route.error");
}
Expand Down Expand Up @@ -239,6 +242,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
const handleClearStationLeg = useCallback(() => {
if (stationLegAbortRef.current) { stationLegAbortRef.current.abort(); stationLegAbortRef.current = null; }
setStationLegRoutes(null);
setSelectedStationId(null);
if (!routeAbortRef.current) setIsRouteLoading(false);
}, []);

Expand All @@ -256,17 +260,30 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
const [detourMap, setDetourMap] = useState<Record<string, number>>({});
const [detoursLoading, setDetoursLoading] = useState(false);

// Stable key based on station IDs — only changes when actual stations change,
// not when currency conversion updates the collection reference.
const primaryStationsRef = useRef(primaryStations);
primaryStationsRef.current = primaryStations;
const stationKey = useMemo(() => {
const ids = primaryStations.features
.filter((f) => f.properties.routeFraction != null)
.map((f) => f.properties.id);
ids.sort();
return ids.join(",");
}, [primaryStations]);

useEffect(() => {
if (detourAbortRef.current) detourAbortRef.current.abort();
setDetourMap({});

const stations = primaryStationsRef.current;
const route = routeState?.routes[routeState.primaryIndex];
if (!route || primaryStations.features.length === 0) {
if (!route || stations.features.length === 0) {
setDetoursLoading(false);
return;
}

const eligible = primaryStations.features
const eligible = stations.features
.filter((f) => f.properties.routeFraction != null)
.sort((a, b) => (a.properties.routeFraction ?? 0) - (b.properties.routeFraction ?? 0));

Expand Down Expand Up @@ -386,7 +403,7 @@ export function HomeClient({ defaultFuel, center, zoom, clusterStations, locale
controller.abort();
if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; }
};
}, [primaryStations, routeState]);
}, [stationKey, routeState]);

// Enrich primary stations with real detour values
const enrichedStations: StationsGeoJSONCollection = (() => {
Expand Down
54 changes: 48 additions & 6 deletions src/components/search/search-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PhotonResult } from "@/lib/photon";
import type { Route } from "@/components/map/route-layer";
import type { StationsGeoJSONCollection } from "@/types/station";
Expand Down Expand Up @@ -347,6 +347,17 @@ export function SearchPanel({
// Transient message for station-leg feedback
const [stationLegMsg, setStationLegMsg] = useState<string | null>(null);

// Transient error for station-leg failures (auto-dismisses)
const [stationLegError, setStationLegError] = useState<string | null>(null);
useEffect(() => {
if (routeError && routes) {
setStationLegError(routeError);
const timer = setTimeout(() => setStationLegError(null), 3000);
return () => clearTimeout(timer);
}
setStationLegError(null);
}, [routeError, routes]);

// Add station as route leg — replaces any previous station leg
const handleStationLeg = useCallback(
(coords: [number, number], name: string, routeFraction: number) => {
Expand Down Expand Up @@ -396,6 +407,31 @@ export function SearchPanel({
[origin, destination, phase, waypoints, primaryRoute, t],
);

// Auto-trigger station-leg when a station is selected (from map click or sidebar).
// Uses refs so the effect only fires on selectedStationId changes, not when
// handleStationLeg's closure deps (origin/destination/waypoints) change.
const handleStationLegRef = useRef(handleStationLeg);
handleStationLegRef.current = handleStationLeg;
const primaryStationsRef = useRef(primaryStations);
primaryStationsRef.current = primaryStations;
const prevSelectedRef = useRef(selectedStationId);

useEffect(() => {
// Only fire when selectedStationId actually changes to a new non-null value
if (selectedStationId === prevSelectedRef.current) return;
prevSelectedRef.current = selectedStationId;
if (!selectedStationId || phase !== "route") return;
const stations = primaryStationsRef.current;
if (!stations) return;
const station = stations.features.find((f) => f.properties.id === selectedStationId);
if (!station || station.properties.routeFraction == null) return;
handleStationLegRef.current(
station.geometry.coordinates,
station.properties.brand ?? station.properties.name,
station.properties.routeFraction,
);
}, [selectedStationId, phase]);

const showDest = phase === "destination" || phase === "route";

const [destVisible, setDestVisible] = useState(false);
Expand Down Expand Up @@ -675,20 +711,27 @@ export function SearchPanel({
)}
</div>

{/* Route error */}
{/* Route error — persistent when no routes */}
{routeError && !routes && (
<div className="mt-2 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-center text-xs text-red-600 shadow-lg dark:border-red-800 dark:bg-red-950/40 dark:text-red-400">
{t(routeError)}
</div>
)}
{/* Station-leg error — transient amber toast, auto-clears */}
{stationLegError && routes && (
<div className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-2.5 text-center text-xs text-amber-600 shadow-lg dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-400">
{t(stationLegError)}
</div>
)}

{/* Route info + alternatives — hidden when collapsed */}
{primaryRoute && !collapsed && (
<div className="mt-2 shrink-0 rounded-xl border border-black/[0.08] bg-white/70 shadow-lg backdrop-blur-md dark:border-white/[0.08] dark:bg-gray-900/70">
{/* All routes — selected one is bold, others are clickable */}
{/* All routes — selected one shows preview metrics when active */}
{routes && routes.map((route, i) => {
const color = ROUTE_COLORS[i % ROUTE_COLORS.length];
const isSelected = i === primaryRouteIndex;
const effectiveRoute = (isSelected && displayRoutes?.[0]) || route;
return (
<button
key={i}
Expand All @@ -697,12 +740,12 @@ export function SearchPanel({
>
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span className={isSelected ? "text-gray-500 dark:text-gray-400" : "text-gray-400"}>{formatDistance(route.distance)}</span>
<span className={isSelected ? "text-gray-500 dark:text-gray-400" : "text-gray-400"}>{formatDistance(effectiveRoute.distance)}</span>
</div>
{isSelected && isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-emerald-400/30 border-t-emerald-400" />
) : (
<span className={`text-sm ${isSelected ? "font-semibold text-gray-800 dark:text-gray-100" : "text-gray-500"}`}>{formatDuration(route.duration)}</span>
<span className={`text-sm ${isSelected ? "font-semibold text-gray-800 dark:text-gray-100" : "text-gray-500"}`}>{formatDuration(effectiveRoute.duration)}</span>
)}
</button>
);
Expand Down Expand Up @@ -816,7 +859,6 @@ export function SearchPanel({
key={sid}
onClick={() => {
onFlyTo(station.geometry.coordinates, sid);
handleStationLeg(station.geometry.coordinates, station.properties.brand ?? station.properties.name, station.properties.routeFraction ?? 0);
if (window.matchMedia("(max-width: 639px)").matches) setCollapsed(true);
}}
className={`flex w-full items-center justify-between border-b border-gray-50 px-4 py-2 text-left last:border-b-0 dark:border-gray-800 ${highlight || "hover:bg-gray-50 dark:hover:bg-gray-800"}`}
Expand Down
Loading