improve AI

This commit is contained in:
Andras Schmelczer 2026-03-15 11:09:01 +00:00
parent daf830c5ed
commit b3a7ab40c8
7 changed files with 118 additions and 17 deletions

View file

@ -1,15 +1,27 @@
import { memo, useState, useCallback } from 'react'; import { memo, useState, useCallback } from 'react';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { SparklesIcon } from '../ui/icons/SparklesIcon'; import { SparklesIcon } from '../ui/icons/SparklesIcon';
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
interface AiFilterInputProps { interface AiFilterInputProps {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
errorType: AiFilterErrorType | null;
notes: string | null; notes: string | null;
onSubmit: (query: string) => void; onSubmit: (query: string) => void;
isLoggedIn: boolean;
onLoginRequired: () => void;
} }
export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: AiFilterInputProps) { export default memo(function AiFilterInput({
loading,
error,
errorType,
notes,
onSubmit,
isLoggedIn,
onLoginRequired,
}: AiFilterInputProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const handleSubmit = useCallback( const handleSubmit = useCallback(
@ -17,9 +29,13 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
e.preventDefault(); e.preventDefault();
const trimmed = query.trim(); const trimmed = query.trim();
if (!trimmed || loading) return; if (!trimmed || loading) return;
if (!isLoggedIn) {
onLoginRequired();
return;
}
onSubmit(trimmed); onSubmit(trimmed);
}, },
[query, loading, onSubmit] [query, loading, isLoggedIn, onLoginRequired, onSubmit]
); );
const hasContent = query.trim().length > 0; const hasContent = query.trim().length > 0;
@ -52,7 +68,17 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
</button> </button>
)} )}
</form> </form>
{error && ( {error && errorType === 'verification' && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Please verify your email address to use AI-powered search. Check your inbox for a verification link.
</p>
)}
{error && errorType === 'limit' && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
You've reached the weekly AI usage limit. It will reset automatically next week.
</p>
)}
{error && errorType === 'error' && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}> <p className="mt-1 text-xs text-red-600 dark:text-red-400 truncate" title={error}>
{error} {error}
</p> </p>

View file

@ -15,6 +15,7 @@ import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel'; import { FeatureLabel } from '../ui/FeatureLabel';
import AiFilterInput from './AiFilterInput'; import AiFilterInput from './AiFilterInput';
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
import FeatureBrowser from './FeatureBrowser'; import FeatureBrowser from './FeatureBrowser';
import { TravelTimeCard } from './TravelTimeCard'; import { TravelTimeCard } from './TravelTimeCard';
import { import {
@ -89,8 +90,11 @@ interface FiltersProps {
onTravelTimeToggleBest: (index: number) => void; onTravelTimeToggleBest: (index: number) => void;
aiFilterLoading: boolean; aiFilterLoading: boolean;
aiFilterError: string | null; aiFilterError: string | null;
aiFilterErrorType: AiFilterErrorType | null;
aiFilterNotes: string | null; aiFilterNotes: string | null;
onAiFilterSubmit: (query: string) => void; onAiFilterSubmit: (query: string) => void;
isLoggedIn: boolean;
onLoginRequired: () => void;
isLicensed: boolean; isLicensed: boolean;
onUpgradeClick?: () => void; onUpgradeClick?: () => void;
onResetTutorial?: () => void; onResetTutorial?: () => void;
@ -121,8 +125,11 @@ export default memo(function Filters({
onTravelTimeToggleBest, onTravelTimeToggleBest,
aiFilterLoading, aiFilterLoading,
aiFilterError, aiFilterError,
aiFilterErrorType,
aiFilterNotes, aiFilterNotes,
onAiFilterSubmit, onAiFilterSubmit,
isLoggedIn,
onLoginRequired,
isLicensed, isLicensed,
onUpgradeClick, onUpgradeClick,
onResetTutorial, onResetTutorial,
@ -278,7 +285,7 @@ export default memo(function Filters({
</div> </div>
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto"> <div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} /> <AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
<div className="px-3 pb-2 space-y-2"> <div className="px-3 pb-2 space-y-2">
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5"> <div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => { {(['historical', 'buy', 'rent'] as const).map((type) => {

View file

@ -143,16 +143,29 @@ export default function MapPage({
}); });
const aiFilters = useAiFilters(); const aiFilters = useAiFilters();
const travelTime = useTravelTime(initialTravelTime);
const handleAiFilterSubmit = useCallback( const handleAiFilterSubmit = useCallback(
async (query: string) => { async (query: string) => {
const result = await aiFilters.fetchAiFilters(query); const result = await aiFilters.fetchAiFilters(query);
if (result) handleSetFilters(result.filters); if (!result) return;
handleSetFilters(result.filters);
// Apply travel time filters from AI
if (result.travelTimeFilters.length > 0) {
const newEntries = result.travelTimeFilters.map((tt) => ({
mode: tt.mode,
slug: tt.slug,
label: tt.label,
timeRange: [tt.min ?? 0, tt.max ?? 120] as [number, number],
useBest: false,
}));
travelTime.handleSetEntries(newEntries);
}
}, },
[aiFilters.fetchAiFilters, handleSetFilters] [aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries]
); );
const travelTime = useTravelTime(initialTravelTime);
const handleTravelTimeSetDestination = useCallback( const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string) => { (index: number, slug: string, label: string) => {
travelTime.handleSetDestination(index, slug, label); travelTime.handleSetDestination(index, slug, label);
@ -499,8 +512,11 @@ export default function MapPage({
onTravelTimeToggleBest={travelTime.handleToggleBest} onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading} aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error} aiFilterError={aiFilters.error}
aiFilterErrorType={aiFilters.errorType}
aiFilterNotes={aiFilters.notes} aiFilterNotes={aiFilters.notes}
onAiFilterSubmit={handleAiFilterSubmit} onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'} isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')} onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial} onResetTutorial={tutorial.resetTutorial}

View file

@ -3,7 +3,12 @@ const H = 1.15; // digit slot height in em
function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) { function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
const idx = DIGITS.indexOf(char); const idx = DIGITS.indexOf(char);
if (idx === -1) return <span>{char}</span>; if (idx === -1)
return (
<span className="inline-block" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
{char}
</span>
);
const offset = active ? -idx * H : 0; const offset = active ? -idx * H : 0;

View file

@ -1,22 +1,36 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types'; import type { FeatureFilters } from '../types';
import type { TransportMode } from './useTravelTime';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api'; import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
export interface AiTravelTimeFilter {
mode: TransportMode;
slug: string;
label: string;
min?: number;
max?: number;
}
interface AiFiltersResult { interface AiFiltersResult {
filters: FeatureFilters; filters: FeatureFilters;
travelTimeFilters: AiTravelTimeFilter[];
notes: string; notes: string;
} }
export type AiFilterErrorType = 'auth' | 'verification' | 'limit' | 'error';
interface UseAiFiltersResult { interface UseAiFiltersResult {
fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>; fetchAiFilters: (query: string) => Promise<AiFiltersResult | null>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
errorType: AiFilterErrorType | null;
notes: string | null; notes: string | null;
} }
export function useAiFilters(): UseAiFiltersResult { export function useAiFilters(): UseAiFiltersResult {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<AiFilterErrorType | null>(null);
const [notes, setNotes] = useState<string | null>(null); const [notes, setNotes] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@ -27,6 +41,7 @@ export function useAiFilters(): UseAiFiltersResult {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorType(null);
setNotes(null); setNotes(null);
try { try {
@ -43,12 +58,36 @@ export function useAiFilters(): UseAiFiltersResult {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
throw new Error(text || `HTTP ${response.status}`); if (response.status === 401) {
setErrorType('auth');
setError(text || 'Login required');
} else if (response.status === 403) {
setErrorType('verification');
setError(text || 'Email verification required');
} else if (response.status === 429) {
setErrorType('limit');
setError(text || 'Weekly usage limit reached');
} else {
setErrorType('error');
setError(text || `HTTP ${response.status}`);
}
setLoading(false);
return null;
} }
const json = await response.json(); const json = await response.json();
const travelTimeFilters: AiTravelTimeFilter[] = (json.travel_time_filters || []).map(
(tt: { mode: string; slug: string; label: string; min?: number; max?: number }) => ({
mode: tt.mode as TransportMode,
slug: tt.slug,
label: tt.label,
min: tt.min,
max: tt.max,
})
);
const result: AiFiltersResult = { const result: AiFiltersResult = {
filters: json.filters as FeatureFilters, filters: json.filters as FeatureFilters,
travelTimeFilters,
notes: json.notes || '', notes: json.notes || '',
}; };
setNotes(result.notes || null); setNotes(result.notes || null);
@ -58,11 +97,12 @@ export function useAiFilters(): UseAiFiltersResult {
if (controller.signal.aborted) return null; if (controller.signal.aborted) return null;
logNonAbortError('ai-filters', err); logNonAbortError('ai-filters', err);
const message = err instanceof Error ? err.message : 'Failed to generate filters'; const message = err instanceof Error ? err.message : 'Failed to generate filters';
setErrorType('error');
setError(message); setError(message);
setLoading(false); setLoading(false);
return null; return null;
} }
}, []); }, []);
return { fetchAiFilters, loading, error, notes }; return { fetchAiFilters, loading, error, errorType, notes };
} }

View file

@ -246,7 +246,9 @@ export function useMapData({
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData; const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData; const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
// Compute p5/p95 from visible data for the viewed feature // Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color
// scale stays stable while dragging a filter slider.
const dataRange = useMemo((): [number, number] | null => { const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null; if (!viewFeature) return null;
@ -260,8 +262,8 @@ export function useMapData({
const vals: number[] = []; const vals: number[] = [];
if (usePostcodeView) { if (usePostcodeView) {
if (effectivePostcodeData.length === 0) return null; if (postcodeData.length === 0) return null;
for (const feat of effectivePostcodeData) { for (const feat of postcodeData) {
if (bounds) { if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number]; const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
@ -271,8 +273,8 @@ export function useMapData({
if (typeof val === 'number' && !isNaN(val)) vals.push(val); if (typeof val === 'number' && !isNaN(val)) vals.push(val);
} }
} else { } else {
if (data.length === 0) return null; if (rawData.length === 0) return null;
for (const item of data) { for (const item of rawData) {
if (bounds) { if (bounds) {
const { lat, lon } = item; const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east) if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
@ -289,7 +291,7 @@ export function useMapData({
percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
]; ];
}, [viewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]); }, [viewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
// Color range for the legend and hex coloring // Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => { const colorRange = useMemo((): [number, number] | null => {

View file

@ -83,6 +83,10 @@ export function useTravelTime(initial?: TravelTimeInitial) {
[] []
); );
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
setEntries(newEntries);
}, []);
/** Entries that have a destination selected (slug is set) */ /** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo( const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''), () => entries.filter((e) => e.slug !== ''),
@ -95,6 +99,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
handleAddEntry, handleAddEntry,
handleRemoveEntry, handleRemoveEntry,
handleSetDestination, handleSetDestination,
handleSetEntries,
handleTimeRangeChange, handleTimeRangeChange,
handleToggleBest, handleToggleBest,
}; };