Changes again

This commit is contained in:
Andras Schmelczer 2026-03-15 21:14:50 +00:00
parent f4de0eeb9f
commit 479ef92236
4 changed files with 160 additions and 95 deletions

View file

@ -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,19 +109,49 @@ export default function FeatureBrowser({
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{showTravelModes && (
<div className="shrink-0">
{mergedGrouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
<CollapsibleGroupHeader
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{visibleModes.length}
{group.features.length +
(group.name === 'Transport' && showTravelModes ? visibleModes.length : 0)}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) &&
{isExpanded && (
<>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
);
})}
{group.name === 'Transport' &&
showTravelModes &&
visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
@ -143,51 +185,12 @@ export default function FeatureBrowser({
</div>
);
})}
</div>
</>
)}
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>
{isExpanded &&
group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
);
})}
</div>
);
})}
{grouped.length === 0 ? (
{mergedGrouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}

View file

@ -368,22 +368,38 @@ export default memo(function Filters({
Finding the Perfect Postcode
</button>
</div>
{travelTimeEntries.length > 0 && (
<div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
Browse features below and click + to add a filter
</p>
)}
{mergedGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.name);
const isTransport = group.name === 'Transport';
const groupCount =
group.features.length + (isTransport ? travelTimeEntries.length : 0);
return (
<div key={group.name}>
<CollapsibleGroupHeader
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
{groupCount}
</span>
</CollapsibleGroupHeader>
{!collapsedGroups.has('Travel Time') && (
{isExpanded && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
{isTransport &&
travelTimeEntries.map((entry, index) => (
<div
key={`tt_${index}`}
data-filter-name={`tt_${index}`}
className="scroll-mt-10"
>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
@ -401,33 +417,6 @@ export default memo(function Filters({
/>
</div>
))}
</div>
)}
</div>
)}
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
Browse features below and click + to add a filter
</p>
)}
{enabledGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.name);
return (
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>
{isExpanded && (
<div className="px-2 py-1 space-y-1">
{group.features.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];

View file

@ -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<string, unknown> = {
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';
}
}

View file

@ -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