Updates
Some checks failed
CI / Frontend (lint + typecheck) (push) Failing after 3m45s
CI / Rust (lint + test) (push) Failing after 5m15s
CI / Python (lint + test) (push) Failing after 5m17s
Build and publish Docker image / build-and-push (push) Failing after 7m15s

This commit is contained in:
Andras Schmelczer 2026-03-28 12:00:15 +00:00
parent 7591e5fc05
commit 89a85e9a0c
22 changed files with 1006 additions and 899 deletions

View file

@ -6,7 +6,7 @@ import type { AiFilterErrorType } from '../../hooks/useAiFilters';
const EXAMPLE_QUERIES = [
'Safe area near good schools',
'30 min commute to Kings Cross, under 500k',
'30 min commute to Kings Cross, under \u00A3500k',
'Quiet village, 3 bed, fast broadband',
];
@ -177,7 +177,7 @@ export default memo(function AiFilterInput({
resizeTextarea();
}}
onKeyDown={handleKeyDown}
placeholder="e.g. quiet area, under 400k, near good schools..."
placeholder="e.g. quiet area, under £400k, near good schools..."
className="flex-1 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800 resize-none overflow-hidden"
rows={1}
style={{ maxHeight: '6rem' }}

View file

@ -109,8 +109,7 @@ export default function AreaPane({
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '}
{isPostcode ? 'postcode' : 'area'}
Stats for all properties in this {isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{stats && stats.count > 0 && (

View file

@ -200,15 +200,15 @@ export default function FeatureBrowser({
/>
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
Choose the filters that matter to you. The map updates as you go.
</p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
The biggest financial decision of your life deserves proper tools behind it.
See crime, schools, noise, broadband, and 50+ more filters across all of England.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
Don&apos;t leave it to chance.
One-time payment, lifetime access.
</p>
<button
onClick={onUpgradeClick}

View file

@ -1,4 +1,4 @@
import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { ChevronIcon, LightbulbIcon } from '../ui/icons';
@ -396,6 +396,16 @@ export default memo(function Filters({
return scales;
}, [features]);
// Insert travel time cards right before the first Transport feature,
// so they visually group with their category.
const travelInsertIdx = useMemo(() => {
const idx = enabledFeatureList.findIndex((f) => f.group === 'Transport');
if (idx >= 0) return idx;
// No transport features enabled — place after Properties, before next group
const afterProps = enabledFeatureList.findIndex((f) => f.group !== 'Properties');
return afterProps >= 0 ? afterProps : enabledFeatureList.length;
}, [enabledFeatureList]);
const badgeCount = enabledFeatureList.length + activeEntryCount;
return (
@ -460,70 +470,71 @@ export default memo(function Filters({
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
Add filters below to narrow the map to areas that match
Add filters below to narrow the map to areas that match your criteria
</p>
)}
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
{enabledFeatureList.map((feature) => {
{enabledFeatureList.map((feature, featureIdx) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div
key={feature.name}
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={val}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
))}
</PillGroup>
</div>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={val}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
/>
))}
</PillGroup>
</div>
</Fragment>
);
}
@ -561,66 +572,111 @@ export default memo(function Filters({
})();
return (
<div
key={feature.name}
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<div className="flex md:block items-start gap-1.5">
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={feature}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}
/>
</div>
<div className="flex md:block items-start gap-1.5">
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
</div>
</div>
</div>
</div>
</Fragment>
);
})}
{travelInsertIdx >= enabledFeatureList.length && travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) => onTravelTimeSetDestination(index, slug, label, lat, lon)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
</div>
</div>
</div>
@ -662,8 +718,8 @@ export default memo(function Filters({
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
add filters. The areas that survive are your best matches.
Start with your must-haves, then layer on nice-to-haves. The map narrows as you add
filters. The areas left are your best matches.
</p>
<div className="space-y-2">
@ -673,7 +729,7 @@ export default memo(function Filters({
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">
Budget &amp; basics
Budget and basics
</span>{' '}
(price range, floor area, property type)
</p>
@ -720,14 +776,14 @@ export default memo(function Filters({
</span>
<p className="text-warm-600 dark:text-warm-300">
<span className="font-semibold text-navy-950 dark:text-warm-100">Energy</span>{' '}
(EPC ratings for lower bills and fewer surprises)
(EPC ratings, insulation, heating costs)
</p>
</div>
</div>
<p className="text-warm-500 dark:text-warm-400 italic text-xs">
Tip: if nothing survives, relax one constraint at a time to see which compromise
unlocks the most options.
Tip: if nothing matches, relax one constraint at a time to see which trade-off opens
up the most options.
</p>
{onResetTutorial && (

View file

@ -140,10 +140,8 @@ export default function POIPane({
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
include public transport stops, shops, restaurants, healthcare facilities, leisure
venues, and more. Data is filtered and mapped to friendly names with exhaustive
category coverage.
Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants,
healthcare, leisure, and more. Updated regularly with complete category coverage.
</p>
</InfoPopup>
)}

View file

@ -122,9 +122,9 @@ export function TravelTimeCard({
{showBestInfo && (
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey if
you time your departure to catch optimal connections. The default uses the{' '}
<strong>median</strong>, representing a typical journey regardless of when you leave.
Uses the fastest realistic journey time (if you time your departure well and catch good
connections). The default uses the <strong>median</strong>, representing a typical journey
regardless of when you leave.
</p>
</InfoPopup>
)}