diff --git a/finder/zoopla.py b/finder/zoopla.py index 4cddc17..ecd8a4b 100644 --- a/finder/zoopla.py +++ b/finder/zoopla.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e2a1ce..8c9bbc9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ? ( ) : activePage === 'account' && user ? ( - + ) : activePage === 'invite' && inviteCode ? ( void }) { + const [editing, setEditing] = useState(false); + const [text, setText] = useState(value); + const inputRef = useRef(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 ( + 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 ( +

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} +

+ ); +} + function SavedSearchesTab({ searches, loading, onDelete, onUpdateNotes, + onUpdateName, onOpen, }: { searches: SavedSearch[]; loading: boolean; onDelete: (id: string) => Promise; onUpdateNotes: (id: string, notes: string) => void; + onUpdateName: (id: string, name: string) => void; onOpen: (params: string) => void; }) { const [deleteConfirmId, setDeleteConfirmId] = useState(null); @@ -229,9 +284,12 @@ function SavedSearchesTab({ )}
-

- {search.name} -

+
+ onUpdateName(search.id, name)} + /> +

{formatRelativeTime(search.created)}

@@ -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; 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} /> ) : ( diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index c321e75..fbe93d7 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -186,8 +186,8 @@ export default function HomePage({ tabs, one postcode at a time.

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

diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index 336a56d..d186f80 100644 --- a/frontend/src/components/learn/LearnPage.tsx +++ b/frontend/src/components/learn/LearnPage.tsx @@ -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: diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 92bdf5b..7aec19a 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -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 ( @@ -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 (
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 }} /> 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 }} />
); @@ -144,10 +152,10 @@ function SliderLabels({ return (
- + {minLabel} - + {maxLabel}
@@ -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" > -
+
@@ -452,10 +462,7 @@ export default memo(function Filters({
{travelTimeEntries.map((entry, index) => ( -
+
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({ 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({
-
+
{!addFilterCollapsed && (
diff --git a/frontend/src/components/map/HistogramLegend.tsx b/frontend/src/components/map/HistogramLegend.tsx index 6e59922..421c30a 100644 --- a/frontend/src/components/map/HistogramLegend.tsx +++ b/frontend/src/components/map/HistogramLegend.tsx @@ -20,7 +20,7 @@ export default function HistogramLegend() {
Dashed line{' '} - indicates the global average + indicates the national average
diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx index a079aa2..e5fa5da 100644 --- a/frontend/src/components/map/JourneyInstructions.tsx +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -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 - - + +
@@ -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 - - + +
diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx index 08d0d7e..1dbce65 100644 --- a/frontend/src/components/ui/FeatureIcons.tsx +++ b/frontend/src/components/ui/FeatureIcons.tsx @@ -22,8 +22,8 @@ export function FeatureActions({ return (
{feature.detail && onShowInfo && ( - onShowInfo(feature)} title="Feature info"> - + onShowInfo(feature)} title="Feature info" size="md"> + )} } {feature.name} diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index f48f3c2..73e27f3 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -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(null); const dragActiveRef = useRef(null); const dragValueRef = useRef<[number, number] | null>(null); + const undoStackRef = useRef([]); 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 })); }, []); diff --git a/frontend/src/hooks/useSavedSearches.ts b/frontend/src/hooks/useSavedSearches.ts index 5eaf283..98c9e0e 100644 --- a/frontend/src/hooks/useSavedSearches.ts +++ b/frontend/src/hooks/useSavedSearches.ts @@ -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, }; } diff --git a/frontend/src/hooks/useTravelDestinations.ts b/frontend/src/hooks/useTravelDestinations.ts index de54f6f..f7c5227 100644 --- a/frontend/src/hooks/useTravelDestinations.ts +++ b/frontend/src/hooks/useTravelDestinations.ts @@ -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)); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 98fec79..6e6e06a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -61,8 +61,7 @@ export async function fetchWithRetry( /** 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 { diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts index 925f243..33f7032 100644 --- a/frontend/src/lib/clipboard.ts +++ b/frontend/src/lib/clipboard.ts @@ -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; diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts index 5c89ef5..a86ce38 100644 --- a/frontend/src/lib/external-search.ts +++ b/frontend/src/lib/external-search.ts @@ -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 = diff --git a/scripts/zoopla_experiment.py b/scripts/zoopla_experiment.py index fdcaf9f..aa4f4fc 100755 --- a/scripts/zoopla_experiment.py +++ b/scripts/zoopla_experiment.py @@ -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]:,}") diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index f9ed091..2404ac0 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -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}", diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs index f99630a..6174912 100644 --- a/server-rs/src/pocketbase.rs +++ b/server-rs/src/pocketbase.rs @@ -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, diff --git a/server-rs/src/routes/ai_filters.rs b/server-rs/src/routes/ai_filters.rs index e1e40f6..5616df6 100644 --- a/server-rs/src/routes/ai_filters.rs +++ b/server-rs/src/routes/ai_filters.rs @@ -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, diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs index 125c084..22848fc 100644 --- a/server-rs/src/routes/pb_proxy.rs +++ b/server-rs/src/routes/pb_proxy.rs @@ -22,7 +22,10 @@ static PROXY_CLIENT: LazyLock = LazyLock::new(|| { .expect("Failed to build proxy HTTP client") }); -pub async fn proxy_to_pocketbase(State(shared): State>, req: Request) -> impl IntoResponse { +pub async fn proxy_to_pocketbase( + State(shared): State>, + req: Request, +) -> impl IntoResponse { let state = shared.load_state(); let pb_url = state.pocketbase_url.trim_end_matches('/'); diff --git a/server-rs/src/routes/pois.rs b/server-rs/src/routes/pois.rs index 7b38822..db7b617 100644 --- a/server-rs/src/routes/pois.rs +++ b/server-rs/src/routes/pois.rs @@ -128,7 +128,9 @@ pub struct POICategoriesResponse { groups: Vec, } -pub async fn get_poi_categories(State(shared): State>) -> Json { +pub async fn get_poi_categories( + State(shared): State>, +) -> Json { let state = shared.load_state(); let groups: Vec = state.poi_category_groups.to_vec(); diff --git a/server-rs/src/routes/shorten.rs b/server-rs/src/routes/shorten.rs index 2d82bf1..8c7d123 100644 --- a/server-rs/src/routes/shorten.rs +++ b/server-rs/src/routes/shorten.rs @@ -38,7 +38,10 @@ struct PbRecord { params: String, } -pub async fn post_shorten(State(shared): State>, Json(req): Json) -> Response { +pub async fn post_shorten( + State(shared): State>, + Json(req): Json, +) -> 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>, Json(req): Jso } } -pub async fn get_short_url(State(shared): State>, Path(code): Path) -> Response { +pub async fn get_short_url( + State(shared): State>, + Path(code): Path, +) -> Response { let state = shared.load_state(); if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {