diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 374f8ee..4acfe1e 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -89,7 +89,19 @@ export default function FeatureBrowser({ const showTravelModes = visibleModes.length > 0 && (!search || - 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase())); + 'travel time journey commute car bicycle walking transit transport station tube train'.includes( + search.toLowerCase() + )); + + // Ensure "Transport" group exists when travel modes should be shown + const mergedGrouped = useMemo(() => { + if (!showTravelModes) return grouped; + if (grouped.some((g) => g.name === 'Transport')) return grouped; + const groups = [...grouped]; + const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area'); + groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] }); + return groups; + }, [grouped, showTravelModes]); return ( <> @@ -97,55 +109,7 @@ export default function FeatureBrowser({
- {showTravelModes && ( -
- toggleGroup('Travel Time')} - className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" - > - - {visibleModes.length} - - - {(isSearching || expandedGroups.has('Travel Time')) && - visibleModes.map((mode) => { - const ModeIcon = MODE_ICONS[mode]; - return ( -
-
onAddTravelTimeEntry(mode)} - > - -
- - {MODE_LABELS[mode]} - - - {MODE_DESCRIPTIONS[mode]} - -
-
-
- -
-
- ); - })} -
- )} - {grouped.map((group) => { + {mergedGrouped.map((group) => { const isExpanded = isSearching || expandedGroups.has(group.name); return (
@@ -156,11 +120,13 @@ export default function FeatureBrowser({ className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" > - {group.features.length} + {group.features.length + + (group.name === 'Transport' && showTravelModes ? visibleModes.length : 0)} - {isExpanded && - group.features.map((f) => { + {isExpanded && ( + <> + {group.features.map((f) => { const isPinned = pinnedFeature === f.name; return (
); - })} + })} + {group.name === 'Transport' && + showTravelModes && + visibleModes.map((mode) => { + const ModeIcon = MODE_ICONS[mode]; + return ( +
+
onAddTravelTimeEntry(mode)} + > + +
+ + {MODE_LABELS[mode]} + + + {MODE_DESCRIPTIONS[mode]} + +
+
+
+ +
+
+ ); + })} + + )}
); })} - {grouped.length === 0 ? ( + {mergedGrouped.length === 0 ? ( } title={search ? 'No matching features' : 'All features are active'} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 7692700..9b73d6c 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -368,52 +368,17 @@ export default memo(function Filters({ Finding the Perfect Postcode
- {travelTimeEntries.length > 0 && ( -
- toggleGroup('Travel Time')} - className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" - > - - {travelTimeEntries.length} - - - {!collapsedGroups.has('Travel Time') && ( -
- {travelTimeEntries.map((entry, index) => ( -
- onTogglePin(travelFieldKey(entry))} - onSetDestination={(slug, label) => - onTravelTimeSetDestination(index, slug, label) - } - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onToggleBest={() => onTravelTimeToggleBest(index)} - onRemove={() => onTravelTimeRemoveEntry(index)} - /> -
- ))} -
- )} -
- )} - {enabledFeatureList.length === 0 && activeEntryCount === 0 && (

Browse features below and click + to add a filter

)} - {enabledGroups.map((group) => { + {mergedGroups.map((group) => { const isExpanded = !collapsedGroups.has(group.name); + const isTransport = group.name === 'Transport'; + const groupCount = + group.features.length + (isTransport ? travelTimeEntries.length : 0); return (
- {group.features.length} + {groupCount} {isExpanded && (
+ {isTransport && + travelTimeEntries.map((entry, index) => ( +
+ onTogglePin(travelFieldKey(entry))} + onSetDestination={(slug, label) => + onTravelTimeSetDestination(index, slug, label) + } + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onToggleBest={() => onTravelTimeToggleBest(index)} + onRemove={() => onTravelTimeRemoveEntry(index)} + /> +
+ ))} {group.features.map((feature) => { if (feature.type === 'enum') { const selectedValues = (filters[feature.name] as string[]) || []; diff --git a/frontend/src/hooks/useTelemetry.ts b/frontend/src/hooks/useTelemetry.ts new file mode 100644 index 0000000..ad03839 --- /dev/null +++ b/frontend/src/hooks/useTelemetry.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; +import { apiUrl } from '../lib/api'; + +/** + * Sends a telemetry beacon every 30 seconds with session duration + * and the number of active filters (parsed from the URL `f` param). + * On the first beacon, also sends the entry path and referrer domain. + */ +export function useTelemetry() { + const startTime = useRef(Date.now()); + const entryPath = useRef(window.location.pathname); + const referrer = useRef(extractReferrerDomain()); + const sentEntry = useRef(false); + + useEffect(() => { + const send = () => { + const sessionSeconds = Math.round((Date.now() - startTime.current) / 1000); + + // Count active filters from URL (filters are encoded as `f=name:min:max;;name:val`) + const params = new URLSearchParams(window.location.search); + const filterStr = params.get('f') || ''; + const filterCount = filterStr ? filterStr.split(';;').length : 0; + + const payload: Record = { + session_seconds: sessionSeconds, + filter_count: filterCount, + }; + + // Include entrypoint info on first beacon only + if (!sentEntry.current) { + payload.entry_path = entryPath.current; + payload.referrer = referrer.current; + sentEntry.current = true; + } + + navigator.sendBeacon( + apiUrl('telemetry'), + new Blob([JSON.stringify(payload)], { type: 'application/json' }) + ); + }; + + const interval = setInterval(send, 30_000); + return () => clearInterval(interval); + }, []); +} + +/** Extract the referrer domain, or "direct" if none / same-origin. */ +function extractReferrerDomain(): string { + if (!document.referrer) return 'direct'; + try { + const url = new URL(document.referrer); + // Same-origin navigation isn't a real external referrer + if (url.origin === window.location.origin) return 'direct'; + return url.hostname; + } catch { + return 'direct'; + } +} diff --git a/server-rs/src/data/property.rs b/server-rs/src/data/property.rs index a93293e..eaabfb4 100644 --- a/server-rs/src/data/property.rs +++ b/server-rs/src/data/property.rs @@ -538,6 +538,21 @@ impl PropertyData { Ok(joined) }; let listings_buy = load_listings(listings_buy_path, "buy")?; + // Derive "Asking price per sqm" if not already present + let listings_buy = if listings_buy.schema().get("Asking price per sqm").is_none() { + listings_buy + .lazy() + .with_column( + (col("Asking price").cast(DataType::Float64) + / col("Total floor area (sqm)")) + .round(0) + .alias("Asking price per sqm"), + ) + .collect() + .context("Failed to derive Asking price per sqm")? + } else { + listings_buy + }; let listings_rent = load_listings(listings_rent_path, "rent")?; // Concatenate all rows into a single DataFrame