Changes again
This commit is contained in:
parent
f4de0eeb9f
commit
479ef92236
4 changed files with 160 additions and 95 deletions
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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[]) || [];
|
||||
|
|
|
|||
58
frontend/src/hooks/useTelemetry.ts
Normal file
58
frontend/src/hooks/useTelemetry.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue