deploy
This commit is contained in:
parent
3fa95819e3
commit
e9a06417ad
32 changed files with 1531 additions and 407 deletions
|
|
@ -27,21 +27,24 @@ describe('useFilters', () => {
|
|||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
result.current.handleDragStart('price', [0, 100]);
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('price');
|
||||
expect(result.current.viewSource).toBe('drag');
|
||||
expect(result.current.dragValue).toEqual([0, 100]);
|
||||
expect(result.current.filterRange).toEqual([0, 100]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.dragValue).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([0, 100]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
result.current.handleDragStart('price', [0, 100]);
|
||||
result.current.handleDragChange([10, 90]);
|
||||
});
|
||||
|
||||
|
|
@ -55,4 +58,29 @@ describe('useFilters', () => {
|
|||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([10, 90]);
|
||||
});
|
||||
|
||||
it('uses the provided initial range for drag-only feature keys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: {},
|
||||
features,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('tt_car_station', [15, 45]);
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('tt_car_station');
|
||||
expect(result.current.dragValue).toEqual([15, 45]);
|
||||
expect(result.current.filterRange).toEqual([15, 45]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.dragValue).toBeNull();
|
||||
expect(result.current.filters).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -416,10 +416,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(name: string) => {
|
||||
(name: string, initialValue?: [number, number]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return;
|
||||
pendingDragRef.current = name;
|
||||
setDragValue(initialValue ?? null);
|
||||
dragValueRef.current = initialValue ?? null;
|
||||
setActiveFeature(name);
|
||||
},
|
||||
[features]
|
||||
|
|
@ -440,6 +442,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
// Click without drag — no filter value was changed, just clear preview state.
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
dragValueRef.current = null;
|
||||
return;
|
||||
}
|
||||
const af = dragActiveRef.current;
|
||||
|
|
@ -458,6 +462,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
if (pendingDragRef.current) {
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
dragValueRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const dv = dragValueRef.current;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
|
|||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
|
|
@ -70,12 +71,18 @@ export function useMapData({
|
|||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
const [currentVisibleView, setCurrentVisibleView] = useState<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||||
|
||||
// Drag preview state
|
||||
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||||
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
||||
const [dragDataKey, setDragDataKey] = useState<string>('');
|
||||
const dragFeatureRef = useRef<string | null>(null);
|
||||
const dragAbortRef = useRef<AbortController | null>(null);
|
||||
const activeFeatureRef = useRef<string | null>(null);
|
||||
|
|
@ -119,32 +126,19 @@ export function useMapData({
|
|||
);
|
||||
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
|
||||
|
||||
// Build the travel param string from entries with destinations.
|
||||
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
|
||||
// When excludeFieldKey is set, that entry uses a wide range (0:1440) instead of
|
||||
// the committed range. This still filters out rows with no travel data (the server
|
||||
// skips rows where minutes=None when any range is set) while including all actual values.
|
||||
// Format: mode:slug[:best][:min:max]. For drag preview, the active travel
|
||||
// filter uses an unbounded range so rows with travel data stay visible.
|
||||
const buildTravelParam = useCallback(
|
||||
(excludeFieldKey?: string): string => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let seg = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) seg += ':best';
|
||||
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
|
||||
if (isExcluded) {
|
||||
seg += ':0:1440';
|
||||
} else if (entry.timeRange) {
|
||||
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
segments.push(seg);
|
||||
}
|
||||
return segments.join('|');
|
||||
},
|
||||
(excludeFieldKey?: string): string =>
|
||||
serializeTravelParam(travelTimeEntries, excludeFieldKey, true),
|
||||
[travelTimeEntries]
|
||||
);
|
||||
|
||||
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
|
||||
const filterStateKey = useMemo(
|
||||
() => `${filtersParam}|${travelParam}`,
|
||||
[filtersParam, travelParam]
|
||||
);
|
||||
const boundsParam = useMemo(
|
||||
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
|
||||
[bounds]
|
||||
|
|
@ -176,28 +170,13 @@ export function useMapData({
|
|||
]
|
||||
);
|
||||
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
|
||||
|
||||
// Keep activeFeatureRef in sync
|
||||
useEffect(() => {
|
||||
activeFeatureRef.current = activeFeature;
|
||||
}, [activeFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFeature) return;
|
||||
latestDragRequestKeyRef.current = '';
|
||||
dragFeatureRef.current = null;
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
}, [activeFeature]);
|
||||
|
||||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||||
// For regular filters: excludes the filter from the filter string.
|
||||
// For travel time: excludes the time range from that entry's travel param segment.
|
||||
useEffect(() => {
|
||||
if (!activeFeature || !bounds) return;
|
||||
|
||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||
dragAbortRef.current = new AbortController();
|
||||
const previousDragStateRef = useRef<{ activeFeature: string | null; filterStateKey: string }>({
|
||||
activeFeature: null,
|
||||
filterStateKey,
|
||||
});
|
||||
const resetPreviewScaleAfterSliderRef = useRef(false);
|
||||
const activeDragRequest = useMemo(() => {
|
||||
if (!activeFeature || !bounds) return null;
|
||||
|
||||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
|
|
@ -218,7 +197,58 @@ export function useMapData({
|
|||
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
|
||||
shareCode ?? '',
|
||||
].join('|');
|
||||
|
||||
return { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey };
|
||||
}, [
|
||||
activeFeature,
|
||||
bounds,
|
||||
buildTravelParam,
|
||||
dataViewFeature,
|
||||
features,
|
||||
filters,
|
||||
getBackendFeatureName,
|
||||
resolution,
|
||||
shareCode,
|
||||
travelParam,
|
||||
usePostcodeView,
|
||||
viewFeatureIsEnum,
|
||||
]);
|
||||
|
||||
// Keep activeFeatureRef in sync
|
||||
useEffect(() => {
|
||||
activeFeatureRef.current = activeFeature;
|
||||
}, [activeFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousDragStateRef.current;
|
||||
if (!activeFeature && previous.activeFeature && previous.filterStateKey !== filterStateKey) {
|
||||
resetPreviewScaleAfterSliderRef.current = true;
|
||||
}
|
||||
previousDragStateRef.current = { activeFeature, filterStateKey };
|
||||
}, [activeFeature, filterStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFeature) return;
|
||||
latestDragRequestKeyRef.current = '';
|
||||
dragFeatureRef.current = null;
|
||||
setDragDataKey('');
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
}, [activeFeature]);
|
||||
|
||||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||||
// For regular filters: excludes the filter from the filter string.
|
||||
// For travel time: excludes the time range from that entry's travel param segment.
|
||||
useEffect(() => {
|
||||
if (!activeFeature || !activeDragRequest) return;
|
||||
|
||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||
dragAbortRef.current = new AbortController();
|
||||
|
||||
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
|
||||
latestDragRequestKeyRef.current = requestKey;
|
||||
setDragDataKey('');
|
||||
dragFeatureRef.current = null;
|
||||
|
||||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
|
|
@ -234,6 +264,7 @@ export function useMapData({
|
|||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragPostcodeData(json.features);
|
||||
setDragHexData(null);
|
||||
setDragDataKey(requestKey);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
||||
|
|
@ -254,6 +285,7 @@ export function useMapData({
|
|||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragHexData(json.features);
|
||||
setDragPostcodeData(null);
|
||||
setDragDataKey(requestKey);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
||||
|
|
@ -270,15 +302,9 @@ export function useMapData({
|
|||
};
|
||||
}, [
|
||||
activeFeature,
|
||||
bounds,
|
||||
resolution,
|
||||
filters,
|
||||
features,
|
||||
usePostcodeView,
|
||||
travelParam,
|
||||
buildTravelParam,
|
||||
activeDragRequest,
|
||||
dataViewFeature,
|
||||
getBackendFeatureName,
|
||||
usePostcodeView,
|
||||
viewFeatureIsEnum,
|
||||
shareCode,
|
||||
]);
|
||||
|
|
@ -386,6 +412,7 @@ export function useMapData({
|
|||
if (!activeFeatureRef.current) {
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
setDragDataKey('');
|
||||
dragFeatureRef.current = null;
|
||||
}
|
||||
setLoading(false);
|
||||
|
|
@ -420,18 +447,16 @@ export function useMapData({
|
|||
shareCode,
|
||||
]);
|
||||
|
||||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||
const data =
|
||||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
|
||||
rawData;
|
||||
const effectivePostcodeData =
|
||||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
|
||||
? dragPostcodeData
|
||||
: null) ?? postcodeData;
|
||||
// Use drag data only when it matches the current view feature and request key.
|
||||
const hasMatchingDragData =
|
||||
Boolean(activeFeature && viewFeature && activeDragRequest) &&
|
||||
dragFeatureRef.current === viewFeature &&
|
||||
dragDataKey === activeDragRequest?.requestKey;
|
||||
const data = (hasMatchingDragData ? dragHexData : null) ?? rawData;
|
||||
const effectivePostcodeData = (hasMatchingDragData ? dragPostcodeData : null) ?? postcodeData;
|
||||
|
||||
// Compute p5/p95 from committed data for the viewed feature.
|
||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||
// scale stays stable while dragging a filter slider.
|
||||
// Compute p5/p95 from the data currently being drawn. During slider drags
|
||||
// this uses the drag-preview data so the colour scale resets to that preview.
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!dataViewFeature) return null;
|
||||
|
||||
|
|
@ -445,8 +470,8 @@ export function useMapData({
|
|||
const vals: number[] = [];
|
||||
|
||||
if (usePostcodeView) {
|
||||
if (postcodeData.length === 0) return null;
|
||||
for (const feat of postcodeData) {
|
||||
if (effectivePostcodeData.length === 0) return null;
|
||||
for (const feat of effectivePostcodeData) {
|
||||
if (bounds) {
|
||||
const [lng, lat] = feat.properties.centroid as [number, number];
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||
|
|
@ -456,8 +481,8 @@ export function useMapData({
|
|||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
} else {
|
||||
if (rawData.length === 0) return null;
|
||||
for (const item of rawData) {
|
||||
if (data.length === 0) return null;
|
||||
for (const item of data) {
|
||||
if (bounds) {
|
||||
const { lat, lon } = item;
|
||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||
|
|
@ -474,7 +499,7 @@ export function useMapData({
|
|||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
];
|
||||
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||||
}, [dataViewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]);
|
||||
|
||||
// Live color range for the legend and hex coloring.
|
||||
const liveColorRange = useMemo((): [number, number] | null => {
|
||||
|
|
@ -505,7 +530,10 @@ export function useMapData({
|
|||
|
||||
useEffect(() => {
|
||||
setFrozenPreviewRange((prev) => {
|
||||
if (!pinnedDataViewFeature) return prev ? null : prev;
|
||||
if (!pinnedDataViewFeature) {
|
||||
resetPreviewScaleAfterSliderRef.current = false;
|
||||
return prev ? null : prev;
|
||||
}
|
||||
return prev?.feature === pinnedDataViewFeature ? prev : null;
|
||||
});
|
||||
}, [pinnedDataViewFeature]);
|
||||
|
|
@ -524,11 +552,13 @@ export function useMapData({
|
|||
: null;
|
||||
if (!rangeToFreeze) return;
|
||||
|
||||
const resetAfterSlider = resetPreviewScaleAfterSliderRef.current;
|
||||
setFrozenPreviewRange((prev) =>
|
||||
prev?.feature === pinnedDataViewFeature
|
||||
? prev
|
||||
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
|
||||
resetAfterSlider || prev?.feature !== pinnedDataViewFeature
|
||||
? { feature: pinnedDataViewFeature, range: rangeToFreeze }
|
||||
: prev
|
||||
);
|
||||
if (resetAfterSlider) resetPreviewScaleAfterSliderRef.current = false;
|
||||
}, [
|
||||
dataRange,
|
||||
dataRequestKey,
|
||||
|
|
@ -583,6 +613,8 @@ export function useMapData({
|
|||
zoom: newZoom,
|
||||
latitude,
|
||||
longitude,
|
||||
visibleLatitude,
|
||||
visibleLongitude,
|
||||
}: ViewChangeParams) => {
|
||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||
if (boundsKey !== prevBoundsRef.current) {
|
||||
|
|
@ -592,6 +624,11 @@ export function useMapData({
|
|||
}
|
||||
setZoom(newZoom);
|
||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||
setCurrentVisibleView({
|
||||
latitude: visibleLatitude ?? latitude,
|
||||
longitude: visibleLongitude ?? longitude,
|
||||
zoom: newZoom,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -599,6 +636,7 @@ export function useMapData({
|
|||
const setInitialView = useCallback(
|
||||
(view: { latitude: number; longitude: number; zoom: number }) => {
|
||||
setCurrentView(view);
|
||||
setCurrentVisibleView(view);
|
||||
setZoom(view.zoom);
|
||||
},
|
||||
[]
|
||||
|
|
@ -613,6 +651,7 @@ export function useMapData({
|
|||
loading,
|
||||
zoom,
|
||||
currentView,
|
||||
currentVisibleView,
|
||||
usePostcodeView,
|
||||
colorRange,
|
||||
canResetPreviewScale,
|
||||
|
|
|
|||
|
|
@ -65,4 +65,51 @@ describe('useTravelTime', () => {
|
|||
expect(result.current.entries).toEqual([replacement]);
|
||||
expect(result.current.activeEntries).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it('deduplicates initial and replacement entries using the tightest range', () => {
|
||||
const wide: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 60],
|
||||
useBest: false,
|
||||
};
|
||||
const tight: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 45],
|
||||
useBest: false,
|
||||
};
|
||||
const replacement: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [20, 40],
|
||||
useBest: true,
|
||||
};
|
||||
const { result } = renderHook(() => useTravelTime({ entries: [wide, tight] }));
|
||||
|
||||
expect(result.current.entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 45],
|
||||
useBest: false,
|
||||
},
|
||||
]);
|
||||
|
||||
act(() => result.current.handleSetEntries([wide, replacement]));
|
||||
|
||||
expect(result.current.entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [20, 40],
|
||||
useBest: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
|
|||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
|
||||
import { dedupeTravelTimeEntries } from '../lib/travel-params';
|
||||
|
||||
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
||||
|
||||
|
|
@ -75,7 +76,9 @@ export interface TravelTimeInitial {
|
|||
}
|
||||
|
||||
export function useTravelTime(initial?: TravelTimeInitial) {
|
||||
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
|
||||
const [entries, setEntries] = useState<TravelTimeEntry[]>(() =>
|
||||
dedupeTravelTimeEntries(initial?.entries ?? [])
|
||||
);
|
||||
|
||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
|
||||
|
|
@ -87,26 +90,32 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
|
||||
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleBest = useCallback((index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||
setEntries(newEntries);
|
||||
setEntries(dedupeTravelTimeEntries(newEntries));
|
||||
}, []);
|
||||
|
||||
/** Entries that have a destination selected (slug is set) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue