Small fixes & fmt

This commit is contained in:
Andras Schmelczer 2026-03-19 21:51:07 +00:00
parent 6b12e21d50
commit f32a552f46
23 changed files with 347 additions and 99 deletions

View file

@ -301,7 +301,7 @@ def _paginate(page, total_results: int, channel: str) -> list[dict]:
if not all_listings or total_results <= len(all_listings):
return all_listings
seen_ids = {l["id"] for l in all_listings}
seen_ids = {listing["id"] for listing in all_listings}
current_url = page.url
page_num = 2

View file

@ -329,6 +329,7 @@ export default function App() {
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onUpdateSearchName={savedSearches.updateSearchName}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
@ -343,10 +344,7 @@ export default function App() {
) : activePage === 'invites' && user ? (
<InvitesPage user={user} />
) : activePage === 'account' && user ? (
<AccountPage
user={user}
onRefreshAuth={refreshAuth}
/>
<AccountPage user={user} onRefreshAuth={refreshAuth} />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}

View file

@ -140,17 +140,72 @@ function formatPropertyDetails(data: SavedPropertyData): string {
return parts.join(' · ');
}
function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setText(value);
}, [value]);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const commit = () => {
setEditing(false);
const trimmed = text.trim();
if (trimmed && trimmed !== value) onSave(trimmed);
else setText(value);
};
if (editing) {
return (
<input
ref={inputRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setText(value);
setEditing(false);
}
}}
onBlur={commit}
className="w-full font-medium text-navy-950 dark:text-warm-100 bg-warm-50 dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-teal-400"
/>
);
}
return (
<h3
onClick={() => setEditing(true)}
className="font-medium text-navy-950 dark:text-warm-100 truncate cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-transparent hover:border-warm-400 dark:hover:border-warm-500"
title="Click to rename"
>
{value}
</h3>
);
}
function SavedSearchesTab({
searches,
loading,
onDelete,
onUpdateNotes,
onUpdateName,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onUpdateName: (id: string, name: string) => void;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -229,9 +284,12 @@ function SavedSearchesTab({
)}
<div className="p-4 flex flex-col flex-1">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<div className="mb-1">
<EditableName
value={search.name}
onSave={(name) => onUpdateName(search.id, name)}
/>
</div>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
@ -414,6 +472,7 @@ export function SavedPage({
searchesLoading,
onDeleteSearch,
onUpdateSearchNotes,
onUpdateSearchName,
onOpenSearch,
savedProperties,
propertiesLoading,
@ -425,6 +484,7 @@ export function SavedPage({
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise<void>;
onUpdateSearchNotes: (id: string, notes: string) => void;
onUpdateSearchName: (id: string, name: string) => void;
onOpenSearch: (params: string) => void;
savedProperties: SavedProperty[];
propertiesLoading: boolean;
@ -470,6 +530,7 @@ export function SavedPage({
loading={searchesLoading}
onDelete={onDeleteSearch}
onUpdateNotes={onUpdateSearchNotes}
onUpdateName={onUpdateSearchName}
onOpen={onOpenSearch}
/>
) : (

View file

@ -186,8 +186,8 @@ export default function HomePage({
tabs, one postcode at a time.
</p>
<p>
We flip that. Tell us what you need (budget, commute, schools, safety) and we show
you every area in England that qualifies. No guesswork. No wasted viewings.
We flip that. Tell us what you need (budget, commute, schools, safety) and we show you
every area in England that qualifies. No guesswork. No wasted viewings.
</p>
</div>
</div>

View file

@ -194,7 +194,7 @@ const FAQ_SECTIONS: FAQSection[] = [
{
question: 'How can I check if an area is safe before I move there?',
answer:
"We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.",
'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
},
{
question:

View file

@ -73,7 +73,7 @@ function EditableLabel({
if (e.key === 'Escape') setEditing(false);
}}
onBlur={commit}
className="absolute -translate-x-1/2 w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
style={style}
/>
);
@ -81,7 +81,7 @@ function EditableLabel({
return (
<span
className={`absolute -translate-x-1/2 cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
className={`absolute cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
style={style}
onClick={startEdit}
>
@ -119,24 +119,32 @@ function SliderLabels({
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw);
// Smoothly spread labels apart as thumbs get close to prevent overlap.
// t=1 (centered) when far apart, t=0 (split) when touching.
const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
const gapPct = rightPct - leftPct;
const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
const leftTranslate = `translateX(${-100 + t * 50}%)`;
const rightTranslate = `translateX(${-t * 50}%)`;
if (feature && onValueChange) {
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<EditableLabel
value={labels[0]}
formatted={minLabel}
onCommit={(v) => onValueChange([v, labels[1]])}
onCommit={(v) => onValueChange([Math.min(v, labels[1]), labels[1]])}
prefix={feature.prefix}
suffix={feature.suffix}
style={{ left: `${leftPct}%` }}
style={{ left: `${leftPct}%`, transform: leftTranslate }}
/>
<EditableLabel
value={labels[1]}
formatted={maxLabel}
onCommit={(v) => onValueChange([labels[0], v])}
onCommit={(v) => onValueChange([labels[0], Math.max(v, labels[0])])}
prefix={feature.prefix}
suffix={feature.suffix}
style={{ left: `${rightPct}%` }}
style={{ left: `${rightPct}%`, transform: rightTranslate }}
/>
</div>
);
@ -144,10 +152,10 @@ function SliderLabels({
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
<span className="absolute" style={{ left: `${leftPct}%`, transform: leftTranslate }}>
{minLabel}
</span>
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
<span className="absolute" style={{ left: `${rightPct}%`, transform: rightTranslate }}>
{maxLabel}
</span>
</div>
@ -391,7 +399,9 @@ export default memo(function Filters({
ref={containerRef}
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
>
<div className={`shrink-0 md:shrink md:min-h-0 flex flex-col ${addFilterCollapsed ? '' : 'md:basis-[40%]'}`}>
<div
className={`shrink-0 md:shrink md:min-h-0 flex flex-col ${addFilterCollapsed ? '' : 'md:basis-[40%]'}`}
>
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
@ -452,10 +462,7 @@ export default memo(function Filters({
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div
key={`tt_${index}`}
data-filter-name={`tt_${index}`}
>
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
@ -464,9 +471,7 @@ export default memo(function Filters({
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) =>
onTravelTimeSetDestination(index, slug, label)
}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
@ -560,9 +565,7 @@ export default memo(function Filters({
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
@ -570,9 +573,7 @@ export default memo(function Filters({
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)),
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
@ -606,13 +607,18 @@ export default memo(function Filters({
</div>
</div>
<div className={`shrink-0 md:shrink md:min-h-0 flex flex-col border-t border-warm-200 dark:border-warm-700 ${addFilterCollapsed ? '' : 'md:basis-[60%]'}`}>
<div
className={`shrink-0 md:shrink md:min-h-0 flex flex-col border-t border-warm-200 dark:border-warm-700 ${addFilterCollapsed ? '' : 'md:basis-[60%]'}`}
>
<button
onClick={() => setAddFilterCollapsed((v) => !v)}
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">Add Filter</span>
<ChevronIcon direction={addFilterCollapsed ? 'down' : 'up'} className="w-4 h-4 text-warm-400 dark:text-warm-500" />
<ChevronIcon
direction={addFilterCollapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</button>
{!addFilterCollapsed && (
<div className="md:min-h-0 md:flex-1 flex flex-col">

View file

@ -20,7 +20,7 @@ export default function HistogramLegend() {
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
indicates the global average
indicates the national average
</span>
</div>
</div>

View file

@ -264,8 +264,18 @@ export default function JourneyInstructions({
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
View on Google Maps
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
@ -284,8 +294,18 @@ export default function JourneyInstructions({
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
View on Google Maps
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5" strokeLinecap="round" strokeLinejoin="round" />
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>

View file

@ -22,8 +22,8 @@ export function FeatureActions({
return (
<div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && (
<IconButton onClick={() => onShowInfo(feature)} title="Feature info">
<InfoIcon />
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
<InfoIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton

View file

@ -38,7 +38,7 @@ export function FeatureLabel({
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}
<span
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
>
{feature.name}
</span>

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useRef } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
@ -15,6 +15,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const pendingDragRef = useRef<string | null>(null);
const dragActiveRef = useRef<string | null>(null);
const dragValueRef = useRef<[number, number] | null>(null);
const undoStackRef = useRef<FeatureFilters[]>([]);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -34,17 +35,41 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const meta = features.find((f) => f.name === name);
if (!meta) return;
trackEvent('Filter Add', { feature: name });
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.type === 'numeric' && meta.histogram) {
setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
} else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
}
setFilters((prev) => {
undoStackRef.current.push(prev);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
if (meta.type === 'enum' && meta.values) {
return { ...prev, [name]: [...meta.values!] };
} else if (meta.type === 'numeric' && meta.histogram) {
return { ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] };
} else if (meta.min != null && meta.max != null) {
return { ...prev, [name]: [meta.min!, meta.max!] };
}
return prev;
});
},
[features]
);
const handleUndo = useCallback(() => {
const prev = undoStackRef.current.pop();
if (prev) setFilters(prev);
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
return;
e.preventDefault();
handleUndo();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleUndo]);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
}, []);

View file

@ -167,6 +167,15 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
const updateSearchName = useCallback(async (id: string, name: string) => {
try {
await pb.collection('saved_searches').update(id, { name });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update name');
}
}, []);
return {
searches,
loading,
@ -176,5 +185,6 @@ export function useSavedSearches(userId: string | null) {
saveSearch,
deleteSearch,
updateSearchNotes,
updateSearchName,
};
}

View file

@ -30,8 +30,12 @@ export function useTravelDestinations(mode: TransportMode) {
return res.json();
})
.then((data: { destinations: Destination[] }) => {
cacheRef.current[mode] = data.destinations;
setDestinations(data.destinations);
const normalized = data.destinations.map((d) => ({
...d,
city: d.city === 'City of London' ? 'London' : d.city,
}));
cacheRef.current[mode] = normalized;
setDestinations(normalized);
})
.catch((err) => logNonAbortError('travel destinations', err))
.finally(() => setLoading(false));

View file

@ -61,8 +61,7 @@ export async function fetchWithRetry<T>(
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
export function prewarmScreenshot(params: string): void {
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders())
.catch(() => {}); // best-effort, don't care if it fails
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
}
export async function shortenUrl(params: string): Promise<string> {

View file

@ -1,18 +1,21 @@
/** Copy text to clipboard with execCommand fallback for older browsers. */
export function copyToClipboard(text: string, onSuccess: () => void): void {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
// Fallback if clipboard permission denied
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
});
navigator.clipboard
.writeText(text)
.then(onSuccess)
.catch(() => {
// Fallback if clipboard permission denied
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
});
} else {
const ta = document.createElement('textarea');
ta.value = text;

View file

@ -51,24 +51,23 @@ const RIGHTMOVE_PRICES = [
// Rightmove allowed monthly rent values (pcm)
const RIGHTMOVE_RENTS = [
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000,
3500, 4000, 5000, 7500, 10000, 15000, 25000,
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
4000, 5000, 7500, 10000, 15000, 25000,
];
// OnTheMarket allowed buy prices
const OTM_PRICES = [
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000,
300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000,
700000, 750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000,
7500000, 10000000, 15000000,
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 275000, 300000,
325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000, 600000, 650000, 700000,
750000, 800000, 900000, 1000000, 1250000, 1500000, 2000000, 2500000, 3000000, 5000000, 7500000,
10000000, 15000000,
];
// OnTheMarket allowed monthly rent values (pcm)
const OTM_RENTS = [
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000,
1100, 1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000,
25000,
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
];
// Zoopla allowed buy prices
@ -81,8 +80,8 @@ const ZOOPLA_PRICES = [
// Zoopla allowed monthly rent values (pcm)
const ZOOPLA_RENTS = [
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
4000, 5000, 7500, 10000, 25000,
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
5000, 7500, 10000, 25000,
];
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
@ -133,7 +132,9 @@ export function buildPropertySearchUrls({
// For rent mode, check asking rent first
const priceFilter = isRent
? filters['Asking rent (monthly)']
: (filters['Asking price'] ?? filters['Estimated current price'] ?? filters['Last known price']);
: (filters['Asking price'] ??
filters['Estimated current price'] ??
filters['Last known price']);
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =

View file

@ -264,8 +264,8 @@ def main():
print()
# Summary stats
prices = [l["price"] for l in listings if l["price"]]
beds = [l["beds"] for l in listings if l["beds"]]
prices = [item["price"] for item in listings if item["price"]]
beds = [item["beds"] for item in listings if item["beds"]]
if prices:
print(f"Price range: £{min(prices):,} - £{max(prices):,}")
print(f"Median: £{sorted(prices)[len(prices)//2]:,}")

View file

@ -437,10 +437,7 @@ async fn main() -> anyhow::Result<()> {
.route("/api/features", get(routes::get_features))
.route("/api/hexagons", get(routes::get_hexagons))
.route("/api/postcodes", get(routes::get_postcodes))
.route(
"/api/postcode/{postcode}",
get(routes::get_postcode_lookup),
)
.route("/api/postcode/{postcode}", get(routes::get_postcode_lookup))
.route("/api/pois", get(routes::get_pois))
.route("/api/poi-categories", get(routes::get_poi_categories))
.route("/api/places", get(routes::get_places))
@ -478,10 +475,7 @@ async fn main() -> anyhow::Result<()> {
"/api/checkout",
post(routes::post_checkout).layer(ConcurrencyLimitLayer::new(10)),
)
.route(
"/api/stripe-webhook",
post(routes::post_stripe_webhook),
)
.route("/api/stripe-webhook", post(routes::post_stripe_webhook))
.route(
"/api/invites",
get(routes::get_invites).post(routes::post_invites),
@ -491,10 +485,7 @@ async fn main() -> anyhow::Result<()> {
.route("/s/{code}", get(routes::get_short_url))
.route("/api/telemetry", post(routes::post_telemetry))
.route("/api/reload", post(routes::post_reload))
.route(
"/pb/{*rest}",
any(routes::proxy_to_pocketbase),
)
.route("/pb/{*rest}", any(routes::proxy_to_pocketbase))
// Tile routes use a different state type — kept as closures
.route(
"/api/tiles/{z}/{x}/{y}",

View file

@ -154,6 +154,20 @@ impl Field {
}
}
fn number(name: &str) -> Self {
Self {
name: name.to_string(),
r#type: "number".to_string(),
required: None,
max_select: None,
collection_id: None,
max_size: None,
mime_types: None,
on_create: None,
on_update: None,
}
}
fn autodate(name: &str, on_create: bool, on_update: bool) -> Self {
Self {
name: name.to_string(),
@ -717,6 +731,39 @@ pub async fn ensure_collections(
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
}
if !existing.iter().any(|n| n == "ai_query_logs") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "ai_query_logs".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::relation("user", &users_id),
Field::text("query", true),
Field::text("listing_type", false),
Field::text("response_filters", false),
Field::text("response_notes", false),
Field::number("tokens_used"),
Field::number("rounds"),
Field::text("model", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: None,
view_rule: None,
create_rule: None,
update_rule: None,
delete_rule: None,
},
)
.await?;
} else {
ensure_autodate_fields(client, base_url, &token, "ai_query_logs").await?;
}
Ok(())
}
@ -869,6 +916,56 @@ async fn poll_pocketbase_counts(state: &AppState) {
}
}
/// Insert a record into the `ai_query_logs` collection.
/// Best-effort — logs warnings on failure but does not propagate errors.
#[allow(clippy::too_many_arguments)]
pub async fn log_ai_query(
state: &AppState,
user_id: &str,
query: &str,
listing_type: &str,
response_filters: &str,
response_notes: &str,
tokens_used: u64,
rounds: u64,
) {
let token = match get_superuser_token(state).await {
Ok(tk) => tk,
Err(err) => {
warn!("Failed to auth superuser for AI query log: {err}");
return;
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/ai_query_logs/records");
let res = state
.http_client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"user": user_id,
"query": query,
"listing_type": listing_type,
"response_filters": response_filters,
"response_notes": response_notes,
"tokens_used": tokens_used,
"rounds": rounds,
"model": &state.gemini_model,
}))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
let status = resp.status();
warn!("Failed to log AI query ({status})");
}
Err(err) => warn!("Failed to log AI query: {err}"),
}
}
async fn pb_count(
client: &reqwest::Client,
pb_url: &str,

View file

@ -12,7 +12,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
use crate::data::slugify;
use crate::pocketbase::get_superuser_token;
use crate::pocketbase::{get_superuser_token, log_ai_query};
use crate::routes::{FeatureInfo, FeaturesResponse};
use crate::state::{AppState, SharedState};
use crate::utils::gemini_chat;
@ -783,6 +783,28 @@ pub async fn post_ai_filters(
counter!("ai_tokens_total").increment(total_tokens_accumulated);
counter!("ai_requests_total", "status" => "success").increment(1);
// Log the query to PocketBase (fire-and-forget)
let filters_json = serde_json::to_string(&filters).unwrap_or_default();
let log_state = state.clone();
let log_user_id = user.id.clone();
let log_query = req.query.clone();
let log_listing_type = listing_type.to_string();
let log_notes = notes.clone();
let log_rounds = (round + 1) as u64;
tokio::spawn(async move {
log_ai_query(
&log_state,
&log_user_id,
&log_query,
&log_listing_type,
&filters_json,
&log_notes,
total_tokens_accumulated,
log_rounds,
)
.await;
});
return Ok(Json(AiFiltersResponse {
filters,
travel_time_filters,

View file

@ -22,7 +22,10 @@ static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.expect("Failed to build proxy HTTP client")
});
pub async fn proxy_to_pocketbase(State(shared): State<Arc<SharedState>>, req: Request) -> impl IntoResponse {
pub async fn proxy_to_pocketbase(
State(shared): State<Arc<SharedState>>,
req: Request,
) -> impl IntoResponse {
let state = shared.load_state();
let pb_url = state.pocketbase_url.trim_end_matches('/');

View file

@ -128,7 +128,9 @@ pub struct POICategoriesResponse {
groups: Vec<POICategoryGroup>,
}
pub async fn get_poi_categories(State(shared): State<Arc<SharedState>>) -> Json<POICategoriesResponse> {
pub async fn get_poi_categories(
State(shared): State<Arc<SharedState>>,
) -> Json<POICategoriesResponse> {
let state = shared.load_state();
let groups: Vec<POICategoryGroup> = state.poi_category_groups.to_vec();

View file

@ -38,7 +38,10 @@ struct PbRecord {
params: String,
}
pub async fn post_shorten(State(shared): State<Arc<SharedState>>, Json(req): Json<ShortenRequest>) -> Response {
pub async fn post_shorten(
State(shared): State<Arc<SharedState>>,
Json(req): Json<ShortenRequest>,
) -> Response {
let state = shared.load_state();
let pb_url = state.pocketbase_url.trim_end_matches('/');
@ -86,7 +89,10 @@ pub async fn post_shorten(State(shared): State<Arc<SharedState>>, Json(req): Jso
}
}
pub async fn get_short_url(State(shared): State<Arc<SharedState>>, Path(code): Path<String>) -> Response {
pub async fn get_short_url(
State(shared): State<Arc<SharedState>>,
Path(code): Path<String>,
) -> Response {
let state = shared.load_state();
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {