-
- {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({
-
+
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"
>
Add Filter
-
+
{!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()) {