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,55 +109,7 @@ 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">
<CollapsibleGroupHeader
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => 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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{visibleModes.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) &&
visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div
className="flex items-center gap-2 min-w-0"
onClick={() => onAddTravelTimeEntry(mode)}
>
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>
);
})}
</div>
)}
{grouped.map((group) => {
{mergedGrouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
@ -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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
{group.features.length +
(group.name === 'Transport' && showTravelModes ? visibleModes.length : 0)}
</span>
</CollapsibleGroupHeader>
{isExpanded &&
group.features.map((f) => {
{isExpanded && (
<>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
@ -183,11 +149,48 @@ export default function FeatureBrowser({
/>
</div>
);
})}
})}
{group.name === 'Transport' &&
showTravelModes &&
visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div
className="flex items-center gap-2 min-w-0"
onClick={() => onAddTravelTimeEntry(mode)}
>
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</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,52 +368,17 @@ export default memo(function Filters({
Finding the Perfect Postcode
</button>
</div>
{travelTimeEntries.length > 0 && (
<div>
<CollapsibleGroupHeader
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => 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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
</span>
</CollapsibleGroupHeader>
{!collapsedGroups.has('Travel Time') && (
<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">
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) =>
onTravelTimeSetDestination(index, slug, label)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</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) => {
{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
@ -423,11 +388,35 @@ export default memo(function Filters({
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}
{groupCount}
</span>
</CollapsibleGroupHeader>
{isExpanded && (
<div className="px-2 py-1 space-y-1">
{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}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) =>
onTravelTimeSetDestination(index, slug, label)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
{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';
}
}