deploy
This commit is contained in:
parent
273d7a83ee
commit
084117cea8
48 changed files with 2283 additions and 890 deletions
|
|
@ -14,6 +14,7 @@ import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } fr
|
|||
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
||||
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam } from '../lib/travel-params';
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
|
|
@ -91,19 +92,7 @@ export function useHexagonSelection({
|
|||
return propertiesRequestIdRef.current === requestId;
|
||||
}, []);
|
||||
|
||||
const travelParam = useMemo(() => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let segment = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) segment += ':best';
|
||||
if (entry.timeRange) {
|
||||
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join('|');
|
||||
}, [travelTimeEntries]);
|
||||
const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (
|
||||
|
|
@ -165,9 +154,9 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
||||
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
|
||||
if (!hasStatsFilters) {
|
||||
setUnfilteredAreaCount(null);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +164,9 @@ export function useHexagonSelection({
|
|||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
setUnfilteredAreaCount(stats.count);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters]
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
|
||||
);
|
||||
|
||||
const refreshUnfilteredAreaCount = useCallback(
|
||||
|
|
@ -185,18 +174,19 @@ export function useHexagonSelection({
|
|||
selection: SelectedHexagon,
|
||||
statsCount: number,
|
||||
includeFilters: boolean,
|
||||
requestId: number,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
|
||||
setUnfilteredAreaCount(null);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
|
||||
fetchUnfilteredAreaCount(selection, requestId, signal).catch((error) =>
|
||||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||
);
|
||||
},
|
||||
[fetchUnfilteredAreaCount, hasStatsFilters]
|
||||
[fetchUnfilteredAreaCount, hasStatsFilters, isCurrentAreaRequest]
|
||||
);
|
||||
|
||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||
|
|
@ -321,6 +311,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
|
||||
|
|
@ -330,7 +321,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -342,7 +333,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -514,6 +505,7 @@ export function useHexagonSelection({
|
|||
nextSelection,
|
||||
nextStats.count,
|
||||
areaStatsUseFilters,
|
||||
requestId,
|
||||
controller.signal
|
||||
);
|
||||
refreshProperties(nextSelection);
|
||||
|
|
@ -569,6 +561,9 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
invalidatePropertyRequests();
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
|
||||
setLoadingAreaStats(true);
|
||||
let cancelled = false;
|
||||
|
|
@ -589,7 +584,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
|
|
@ -620,6 +615,7 @@ export function useHexagonSelection({
|
|||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
]);
|
||||
|
|
@ -656,7 +652,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
if (openProperties && stats.count > 0) {
|
||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||
}
|
||||
|
|
@ -696,6 +692,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
|
@ -710,7 +707,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!bounds || selectedCategories.size === 0) {
|
||||
abortControllerRef.current?.abort();
|
||||
setPois([]);
|
||||
return;
|
||||
}
|
||||
|
|
@ -40,6 +45,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
);
|
||||
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
|
||||
const json: POIResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setPois(json.pois || []);
|
||||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch POIs', err);
|
||||
|
|
@ -50,6 +56,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [bounds, selectedCategories]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type { Property } from '../types';
|
||||
import { getNum } from '../lib/property-fields';
|
||||
|
||||
export interface SavedPropertyData {
|
||||
propertyType?: string;
|
||||
propertySubType?: string;
|
||||
builtForm?: string;
|
||||
duration?: string;
|
||||
energyRating?: string;
|
||||
price?: number;
|
||||
estimatedPrice?: number;
|
||||
floorArea?: number;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
id: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
data: SavedPropertyData;
|
||||
notes: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export function useSavedProperties(userId: string | null) {
|
||||
const [properties, setProperties] = useState<SavedProperty[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProperties = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const records = await pb.collection('saved_properties').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
setProperties(
|
||||
records.map((r) => {
|
||||
const raw = r as Record<string, unknown>;
|
||||
let data: SavedPropertyData = {};
|
||||
try {
|
||||
data =
|
||||
typeof raw.data === 'string'
|
||||
? JSON.parse(raw.data)
|
||||
: (raw.data as SavedPropertyData) || {};
|
||||
} catch {
|
||||
// Invalid JSON — use empty data
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
address: raw.address as string,
|
||||
postcode: raw.postcode as string,
|
||||
data,
|
||||
notes: (raw.notes as string) || '',
|
||||
created: r.created,
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load saved properties');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveProperty = useCallback(
|
||||
async (property: Property) => {
|
||||
if (!userId) return;
|
||||
setError(null);
|
||||
try {
|
||||
const data: SavedPropertyData = {
|
||||
propertyType: property.property_type,
|
||||
propertySubType: property.property_sub_type,
|
||||
builtForm: property.built_form,
|
||||
duration: property.duration,
|
||||
energyRating: property.current_energy_rating,
|
||||
price: getNum(property, 'Last known price'),
|
||||
estimatedPrice: getNum(property, 'Estimated current price'),
|
||||
floorArea: getNum(property, 'Total floor area (sqm)'),
|
||||
};
|
||||
|
||||
await pb.collection('saved_properties').create({
|
||||
user: userId,
|
||||
address: property.address || 'Unknown',
|
||||
postcode: property.postcode || '',
|
||||
data: JSON.stringify(data),
|
||||
});
|
||||
trackEvent('Property Save');
|
||||
await fetchProperties();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save property';
|
||||
setError(msg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[userId, fetchProperties]
|
||||
);
|
||||
|
||||
const deleteProperty = useCallback(async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_properties').delete(id);
|
||||
trackEvent('Property Delete');
|
||||
setProperties((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete property');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedPropertyKeys = useMemo(
|
||||
() => new Set(properties.map((p) => `${p.address}|${p.postcode}`)),
|
||||
[properties]
|
||||
);
|
||||
|
||||
const isPropertySaved = useCallback(
|
||||
(address?: string, postcode?: string) =>
|
||||
savedPropertyKeys.has(`${address || ''}|${postcode || ''}`),
|
||||
[savedPropertyKeys]
|
||||
);
|
||||
|
||||
const getSavedPropertyId = useCallback(
|
||||
(address?: string, postcode?: string) => {
|
||||
const key = `${address || ''}|${postcode || ''}`;
|
||||
return properties.find((p) => `${p.address}|${p.postcode}` === key)?.id;
|
||||
},
|
||||
[properties]
|
||||
);
|
||||
|
||||
const updatePropertyNotes = useCallback(async (id: string, notes: string) => {
|
||||
try {
|
||||
await pb.collection('saved_properties').update(id, { notes });
|
||||
setProperties((prev) => prev.map((p) => (p.id === id ? { ...p, notes } : p)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update notes');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
properties,
|
||||
loading,
|
||||
error,
|
||||
fetchProperties,
|
||||
saveProperty,
|
||||
deleteProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
updatePropertyNotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -102,12 +102,12 @@ export function useSavedSearches(userId: string | null) {
|
|||
}, [userId, fetchRecords, startPolling, stopPolling]);
|
||||
|
||||
const saveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
async (name: string, paramsOverride?: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue