Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions client/src/Api.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,17 @@ const Api = {
params: { latitude, longitude }
}).catch(handleError);
},
intersections (text, { signal, limit } = {}) {
return instance.get('/api/geocode/intersections', {
params: { text, limit },
signal,
});
},
intersectionsNearest (latitude, longitude, { n } = {}) {
return instance.get('/api/geocode/intersections/nearest', {
params: { latitude, longitude, n }
}).catch(handleError);
},
},
organizations: {
index (page = 1) {
Expand Down Expand Up @@ -503,8 +514,10 @@ const Api = {
},
},
ai: {
transcribe (audio, mediaType) {
return instance.post('/api/ai/transcribe', { audio, mediaType });
transcribe (audio, mediaType, { mode } = {}) {
return instance.post('/api/ai/transcribe', { audio, mediaType }, {
params: mode ? { mode } : undefined,
});
},
parseId (image, mediaType) {
return instance.post('/api/ai/parse-id', { image, mediaType });
Expand Down
12 changes: 8 additions & 4 deletions client/src/components/AudioRecorder.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function formatTimer (seconds) {
return `${m}:${s.toString().padStart(2, '0')}`;
}

function AudioRecorder ({ onResult, onBusyChange, disabled }) {
function AudioRecorder ({ onResult, onBusyChange, disabled, mode = 'narrative', recordLabel }) {
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const timerRef = useRef(null);
Expand Down Expand Up @@ -118,13 +118,17 @@ function AudioRecorder ({ onResult, onBusyChange, disabled }) {
const pcmData = downsampleToInt16(audioBuffer);
const base64 = int16ToBase64(pcmData);

const response = await Api.ai.transcribe(base64, 'audio/pcm');
const response = await Api.ai.transcribe(base64, 'audio/pcm', { mode });
const text = (response.data.text || '').trim();
if (!text) {
setError('No speech detected. Try again.');
return;
}
onResult(text);
if (mode === 'location') {
onResult(response.data);
} else {
onResult(text);
}
setError(null);
} catch {
setError('Transcription failed. Try again.');
Expand Down Expand Up @@ -189,7 +193,7 @@ function AudioRecorder ({ onResult, onBusyChange, disabled }) {
onClick={startRecording}
disabled={disabled}
>
Record voice
{recordLabel ?? 'Record voice'}
</Button>
)}
</Group>
Expand Down
84 changes: 84 additions & 0 deletions client/src/components/BetweenIntersectionsHint.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { Text } from '@mantine/core';

import Api from '@/Api';

/**
* Renders "between {Street1} and {Street2}" below an address, sourced from the
* two nearest SF intersection nodes per /api/geocode/intersections/nearest.
*
* Renders nothing when:
* - locationType is INTERSECTION (the intersection itself is the context)
* - lat/lng is missing (older records, manual entries)
* - the lookup fails or returns no useful pair
*/
function BetweenIntersectionsHint ({ record }) {
const [pair, setPair] = useState(null);

useEffect(() => {
setPair(null);
if (!record) return;
if (record.locationType === 'INTERSECTION') return;
const lat = Number(record.latitude);
const lng = Number(record.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;

let cancelled = false;
Api.geocode.intersectionsNearest(lat, lng, { n: 3 })
.then((response) => {
if (cancelled) return;
const rows = response?.data ?? [];
// Pick the first two intersections that share at least one street with each
// other — that's the "X between Y and Z" pattern. Fall back to first two if
// none share.
const matched = pickPair(rows);
if (matched) setPair(matched);
})
.catch(() => { /* silent — between context is best-effort */ });
return () => { cancelled = true; };
}, [record?.latitude, record?.longitude, record?.locationType]);

if (!pair) return null;
return (
<Text c='dimmed' size='sm'>
between {pair.cross1} and {pair.cross2}
</Text>
);
}

function pickPair (rows) {
if (!rows || rows.length < 2) return null;
// The interesting case: two adjacent nodes that share one street (we want
// the cross-street name from each, NOT the shared street). Find a pair
// sharing a street.
for (let i = 0; i < rows.length; i++) {
for (let j = i + 1; j < rows.length; j++) {
const a = rows[i];
const b = rows[j];
const aStreets = [a.street1, a.street2];
const bStreets = [b.street1, b.street2];
const shared = aStreets.find(s => bStreets.includes(s));
if (shared) {
const cross1 = aStreets.find(s => s !== shared);
const cross2 = bStreets.find(s => s !== shared);
if (cross1 && cross2 && cross1 !== cross2) {
return {
cross1: titleCase(cross1),
cross2: titleCase(cross2),
};
}
}
}
}
return null;
}

function titleCase (raw) {
const stripped = String(raw).replace(/\b0(\d(ST|ND|RD|TH))\b/gi, '$1');
return stripped
.split(/\s+/)
.map(w => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
.join(' ');
}

export default BetweenIntersectionsHint;
186 changes: 186 additions & 0 deletions client/src/components/LocationAutocomplete.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { forwardRef, useEffect, useRef, useState } from 'react';
import { Autocomplete, Loader } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';

import Api from '@/Api';

/**
* Unified address-or-intersection autocomplete for the incident Location field.
*
* Detects intersection-style input (e.g. "16th & Valencia", "Mission and 24th")
* from a connector token and switches the backend call between
* /api/geocode/search (address typeahead, AWS Location)
* /api/geocode/intersections (DataSF intersection index)
*
* On select:
* - address → form.setValues({ locationType:'ADDRESS', addressLine1, city, state, postalCode, latitude, longitude })
* + clears street1/street2/intersectionId
* - intersection → form.setValues({ locationType:'INTERSECTION', street1, street2, intersectionId, latitude, longitude, city:'San Francisco', state:'CA' })
* + clears addressLine1/addressLine2/postalCode
*/

// Match a connector token between two non-empty sides.
const INTERSECTION_RE = /[&/@\\|]|\b(and|at)\b/i;

function detectIntersectionMode (text) {
if (!text || text.length < 3) return false;
// Numeric leading digit + non-ordinal letter looks like an address ("425 Mission St")
if (/^\d+\s+[A-Za-z]/.test(text) && !/^\d+(st|nd|rd|th)\b/i.test(text)) return false;
return INTERSECTION_RE.test(text);
}

const LocationAutocomplete = forwardRef(function LocationAutocomplete ({ form, rightSection, ...props }, ref) {
const formValues = form.getValues();
const initialDisplay = formValues.locationType === 'INTERSECTION'
? [formValues.street1, formValues.street2].filter(Boolean).join(' & ')
: (formValues.addressLine1 ?? '');
const [value, setValue] = useState(initialDisplay);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const resultsRef = useRef({ mode: null, items: [] });
const abortControllerRef = useRef(null);
// Set true at the top of handleOptionSubmit and cleared on the next tick.
// Any onChange that fires during this window is from the option-submit
// itself (Mantine echoes the picked value through onChange after the pick),
// so we skip the invalidation logic — the option-submit already set the
// values it wants.
const skipInvalidationRef = useRef(false);
const [debounced] = useDebouncedValue(value, 300);

useEffect(() => {
if (!debounced || debounced.length < 3) {
setData([]);
resultsRef.current = { mode: null, items: [] };
return;
}

if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);

const intersectionMode = detectIntersectionMode(debounced);
const apiCall = intersectionMode
? Api.geocode.intersections(debounced, { signal: controller.signal })
: Api.geocode.search(debounced, { signal: controller.signal });

apiCall
.then((response) => {
if (controller.signal.aborted) return;
const items = response?.data ?? [];
resultsRef.current = { mode: intersectionMode ? 'INTERSECTION' : 'ADDRESS', items };
if (intersectionMode) {
setData(items
.filter((r) => r.cnn)
.map((r) => ({
value: r.cnn,
label: r.neighborhood ? `⌗ ${r.label} (${r.neighborhood})` : `⌗ ${r.label}`,
})));
} else {
setData(items
.filter((r) => r.placeId && r.addressLine1)
.map((r) => ({
value: r.placeId,
label: r.neighborhood ? `📍 ${r.addressLine1} (${r.neighborhood})` : `📍 ${r.addressLine1}`,
})));
}
})
.catch((err) => {
if (controller.signal.aborted) return;
console.error('Location search failed:', err);
setData([]);
resultsRef.current = { mode: null, items: [] };
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});

return () => controller.abort();
}, [debounced]);

function handleChange (val) {
setValue(val);
if (skipInvalidationRef.current) {
return;
}
// Real user-typed change. Invalidate the cached neighborhood hint and
// intersectionId — both were tied to a prior selection that no longer
// matches what the user is typing.
const fv = form.getValues();
if (fv.neighborhood || fv.intersectionId) {
form.setValues({ neighborhood: '', intersectionId: '' });
}
}

function handleFocus (e) {
e.target.select();
}

function handleBlur () {
// Free-text save: only persist into the form if we know the current mode.
// For unmatched text, leave the form's current addressLine1 alone — the
// explicit suggestion-pick is the path that writes through to the form.
}

function handleOptionSubmit (key) {
const { mode, items } = resultsRef.current;
const match = items.find((r) => (mode === 'INTERSECTION' ? r.cnn : r.placeId) === key);
if (!match) return;

skipInvalidationRef.current = true;
setTimeout(() => { skipInvalidationRef.current = false; }, 0);

if (mode === 'INTERSECTION') {
const label = `${match.street1Display} & ${match.street2Display}`;
setValue(label);
form.setValues({
locationType: 'INTERSECTION',
street1: match.street1Display,
street2: match.street2Display,
intersectionId: match.cnn,
neighborhood: match.neighborhood ?? '',
city: 'San Francisco',
state: 'CA',
latitude: match.latitude,
longitude: match.longitude,
addressLine1: null,
addressLine2: null,
postalCode: null,
});
} else {
setValue(match.addressLine1);
form.setValues({
locationType: 'ADDRESS',
addressLine1: match.addressLine1,
city: match.city ?? '',
state: match.state ?? '',
postalCode: match.postalCode?.split('-')[0] ?? '',
neighborhood: match.neighborhood ?? '',
latitude: match.latitude ?? '',
longitude: match.longitude ?? '',
street1: null,
street2: null,
intersectionId: null,
});
}
}

return (
<Autocomplete
ref={ref}
{...props}
value={value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onOptionSubmit={handleOptionSubmit}
data={data}
filter={({ options }) => options}
rightSection={loading ? <Loader size={24} /> : rightSection}
/>
);
});

export default LocationAutocomplete;
Loading
Loading