refactor(places): share the place form and the maps search between desktop and phone

usePlaceForm and usePlaceSearch take the form state, the prefill, the
opening hours selection, the duplicate name check, the payload and the
maps search out of PlaceFormModal. MPlaceEditSheet, PlPlaceSearch and
PlCategoryPicker run on the same hooks, and PlTimeFields uses the shared
timeCollisions.

Where the two shells behaved differently the hooks take a dialog or a
sheet variant: the coordinate pair rule, abort and console handling,
when the billing session ends, whether a pick clears the suggestions,
the empty search cue, whether a map link clears the results, and how a
new category is trimmed and guarded. The open effect and the submit flow
stay per shell, because service stops, expense intents and delete arming
differ too much to share.
This commit is contained in:
Maurice
2026-10-09 07:16:39 +00:00
parent b2fd0ae5aa
commit 71289ba43e
11 changed files with 1857 additions and 980 deletions
@@ -0,0 +1,158 @@
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-010: the place form's openings and save
// payload, as the desktop dialog and the phone sheet both build them.
import { describe, expect, it } from 'vitest';
import type { Assignment, Place } from '../../types';
import {
DEFAULT_FORM,
duplicateName,
openingAutoFilled,
openingDetailsSelection,
placeEditForm,
placeFormPayload,
prefillForm,
} from './PlaceFormModal.helpers';
const PLACE = {
id: 3,
name: 'Louvre',
description: 'Museum',
address: 'Rue de Rivoli',
lat: 48.86,
lng: 2.33,
category_id: 4,
place_time: '09:00',
end_time: '10:00',
notes: 'Bring water',
transport_mode: null,
website: 'https://louvre.fr',
google_place_id: null,
amap_poi_id: 'amap-1',
osm_id: 'W1',
} as unknown as Place;
const ASSIGNMENT = {
id: 11,
notes: 'Day note',
place: { place_time: '14:00', end_time: '' },
} as unknown as Assignment;
describe('placeEditForm', () => {
it('FE-PLANNER-PLFORMH-001: reads the place, with its times off the assignment in context and its day note', () => {
expect(placeEditForm(PLACE, ASSIGNMENT)).toEqual({
name: 'Louvre',
description: 'Museum',
address: 'Rue de Rivoli',
lat: '48.86',
lng: '2.33',
category_id: '4',
place_time: '14:00',
end_time: '',
notes: 'Bring water',
transport_mode: 'walking',
website: 'https://louvre.fr',
assignment_notes: 'Day note',
});
});
it("FE-PLANNER-PLFORMH-002: without an assignment the times are the place's and no day note key exists", () => {
const form = placeEditForm(PLACE, null, { phone: '+33' });
expect(form.place_time).toBe('09:00');
expect(form.end_time).toBe('10:00');
expect(form.phone).toBe('+33');
expect('assignment_notes' in form).toBe(false);
});
it('FE-PLANNER-PLFORMH-003: empty coordinates and category stay empty strings', () => {
const bare = { ...PLACE, lat: null, lng: null, category_id: null, name: null } as unknown as Place;
const form = placeEditForm(bare, undefined);
expect([form.lat, form.lng, form.category_id, form.name]).toEqual(['', '', '', '']);
});
});
describe('prefillForm', () => {
it("FE-PLANNER-PLFORMH-004: a blank form at the POI, with what it knew and the shell's own extras", () => {
const form = prefillForm({ lat: 1.5, lng: 2.5, name: 'Cafe', osm_id: 'N9' }, { category_id: '3' });
expect(form).toEqual({
...DEFAULT_FORM,
lat: '1.5',
lng: '2.5',
name: 'Cafe',
address: '',
website: '',
phone: '',
osm_id: 'N9',
category_id: '3',
});
});
});
describe('openingDetailsSelection', () => {
it('FE-PLANNER-PLFORMH-005: the edited place first, then the POI, then nothing', () => {
expect(openingDetailsSelection(PLACE, { lat: 1, lng: 2 })).toEqual({
placeId: 'amap-1',
lat: 48.86,
lng: 2.33,
name: 'Louvre',
});
const unplaced = { ...PLACE, lat: null } as unknown as Place;
expect(openingDetailsSelection(unplaced, { lat: 1, lng: 2, name: 'Cafe' })).toEqual({
placeId: undefined,
lat: 1,
lng: 2,
name: 'Cafe',
});
expect(openingDetailsSelection(unplaced, null)).toBeNull();
});
});
describe('openingAutoFilled', () => {
it('FE-PLANNER-PLFORMH-006: only a POI opening owns fields, and only the ones it filled', () => {
const prefill = { lat: 1, lng: 2, name: 'Cafe', address: '', phone: '+1' };
expect([...openingAutoFilled(null, prefill)].sort()).toEqual(['lat', 'lng', 'name', 'phone']);
expect(openingAutoFilled(PLACE, prefill).size).toBe(0);
expect(openingAutoFilled(null, null).size).toBe(0);
});
});
describe('duplicateName', () => {
it('FE-PLANNER-PLFORMH-007: names the existing place, or the typed name when that one has none', () => {
const form = { ...DEFAULT_FORM, name: 'Louvre', lat: '48.86', lng: '2.33' };
expect(duplicateName(form, [{ name: 'LOUVRE' }])).toBe('LOUVRE');
expect(duplicateName(form, [{ name: null, lat: 48.86001, lng: 2.33 }])).toBe('Louvre');
expect(duplicateName(form, [{ name: 'Orsay', lat: 40, lng: 2 }])).toBeNull();
});
});
describe('placeFormPayload', () => {
const form = {
...DEFAULT_FORM,
name: 'Louvre',
lat: '48.86',
lng: '',
category_id: '',
assignment_notes: 'Day note',
};
it('FE-PLANNER-PLFORMH-008: numbers for the coordinates, null for an empty category, files only when chosen', () => {
const payload = placeFormPayload(form, [], null);
expect(payload.lat).toBe(48.86);
expect(payload.lng).toBeNull();
expect(payload.category_id).toBeNull();
expect(payload._pendingFiles).toBeUndefined();
const pdf = new File(['x'], 'a.pdf');
expect(placeFormPayload(form, [pdf], null)._pendingFiles).toEqual([pdf]);
});
it('FE-PLANNER-PLFORMH-009: the day note travels only with its assignment and only when it changed', () => {
expect('assignment_notes' in placeFormPayload(form, [], null)).toBe(false);
expect('assignment_notes' in placeFormPayload(form, [], { notes: 'Day note' })).toBe(false);
expect(placeFormPayload(form, [], { notes: 'Old note' }).assignment_notes).toBe('Day note');
expect('assignment_notes' in placeFormPayload({ ...form, assignment_notes: '' }, [], { notes: null })).toBe(false);
});
it("FE-PLANNER-PLFORMH-010: a shell's extras are built from the parsed coordinates", () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
expect(payload.at).toEqual([48.86, null]);
});
});
@@ -1,4 +1,5 @@
import type { Assignment, RoadtripStopType } from '@trek/shared'
import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
export interface PlaceFormData {
name: string
@@ -95,6 +96,102 @@ export const DEFAULT_FORM: PlaceFormData = {
website: '',
}
/**
* The place being edited, as the form edits it. Its times are stored per day-assignment,
* not on the pool place, so with an assignment in context (itinerary edit, or a
* single-assignment pool edit) they are read off its embedded place. `extra` is what one
* shell's form adds to the place's own fields, ahead of the day note.
*/
export function placeEditForm(
place: Place,
assignment: Assignment | null | undefined,
extra: Partial<PlaceFormData> = {},
): PlaceFormData {
const timeSource = assignment?.place ?? place
return {
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat != null ? String(place.lat) : '',
lng: place.lng != null ? String(place.lng) : '',
category_id: place.category_id != null ? String(place.category_id) : '',
place_time: timeSource.place_time || '',
end_time: timeSource.end_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
...extra,
// The day-specific note rides only with an assignment in context (#2163);
// otherwise the key stays absent so submit never sends a notes write.
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
}
}
/** A position the form opens on: a POI tapped on the map, or a right-click on it. */
export interface PlacePrefill {
lat: number
lng: number
name?: string
address?: string
website?: string
phone?: string
osm_id?: string
}
/** A blank form at a prefilled position, with what the POI already knew. */
export function prefillForm(prefill: PlacePrefill, extra: Partial<PlaceFormData> = {}): PlaceFormData {
return {
...DEFAULT_FORM,
lat: String(prefill.lat),
lng: String(prefill.lng),
name: prefill.name || '',
address: prefill.address || '',
website: prefill.website || '',
phone: prefill.phone || '',
osm_id: prefill.osm_id,
...extra,
}
}
/**
* What the details column describes when the form opens: the place being edited, else
* whatever a map POI prefilled, else nothing yet.
*/
export function openingDetailsSelection(
place: Pick<Place, 'name' | 'lat' | 'lng' | 'google_place_id' | 'amap_poi_id' | 'osm_id'> | null | undefined,
prefill: PlacePrefill | null | undefined,
): PlaceDetailsSelection | null {
if (place && place.lat != null && place.lng != null) {
return {
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat: Number(place.lat),
lng: Number(place.lng),
name: place.name || '',
}
}
if (prefill) {
return { placeId: prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
}
return null
}
/**
* The fields a fresh opening owns. A blank form owns nothing yet. A POI tapped on the map
* or a right-click place arrives prefilled from a place, so those fields belong to it and
* a later search pick may clear them. An existing place being edited is the opposite:
* everything on that form came out of the database and none of it is a search result's
* to drop.
*/
export function openingAutoFilled(place: unknown, prefill: PlacePrefill | null | undefined): Set<ResultField> {
return new Set(
!place && prefill
? (['name', 'address', 'lat', 'lng', 'website', 'phone', 'osm_id'] as ResultField[]).filter(
(field) => !!prefill[field as keyof PlacePrefill],
)
: [],
)
}
/**
* The fields a maps result owns. Everything else in the form belongs to the
* user and is never touched by picking a place.
@@ -195,6 +292,50 @@ export function findDuplicatePlace(
return null
}
/** The name a likely duplicate (#1152) is announced with, or null when the trip has none. */
export function duplicateName(form: PlaceFormData, places: Parameters<typeof findDuplicatePlace>[1]): string | null {
const dup = findDuplicatePlace(form, places)
return dup ? dup.name || form.name : null
}
/** The form as the save takes it: coordinates as numbers, an empty category as null. */
export interface PlaceFormPayload extends Omit<PlaceFormData, 'lat' | 'lng' | 'category_id'> {
lat: number | null
lng: number | null
category_id: string | null
/** Files chosen before the place existed: uploaded once the save has given it an id. */
_pendingFiles?: File[]
}
/**
* What a save sends. `extra` is what one shell adds after the category, from the
* coordinates being saved. The note of the day in context only travels when that
* assignment is in context AND the note actually changed (#2163): an untouched note must
* not produce a PUT, or a legacy note longer than the field would be re-sent on every
* unrelated save.
*/
export function placeFormPayload<E extends object = object>(
form: PlaceFormData,
pendingFiles: File[],
assignment: Pick<Assignment, 'notes'> | null | undefined,
extra?: (lat: number | null, lng: number | null) => E,
): PlaceFormPayload & E {
const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null
const payload = {
...form,
lat,
lng,
category_id: form.category_id || null,
...(extra ? extra(lat, lng) : {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
} as PlaceFormPayload & E
if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) {
delete payload.assignment_notes
}
return payload
}
/**
* A coordinate pair pasted into the latitude field, as "48.85, 2.35", "48.85;2.35"
* or "48.85 2.35": the two halves, or null for anything else, which the field then
+141 -558
View File
@@ -1,15 +1,13 @@
import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react'
import type { RoadtripStopType } from '@trek/shared'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import PlaceDetailsColumn from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
@@ -19,23 +17,22 @@ import { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours'
import type { PlaceOpeningHours } from '@trek/shared'
import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions,
type PlaceFormData, type ResultField,
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
prefillForm, timeCollisions, type PlaceFormData,
} from './PlaceFormModal.helpers'
import { useNewCategory, usePlaceForm } from './usePlaceForm'
import { usePlaceSearch } from './usePlaceSearch'
import { guessCategoryId } from './placeCategoryGuess'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { NavigationMenu } from '../shared/NavigationMenu'
import { getApiErrorMessage } from '../../utils/apiError'
import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../utils/placeSource'
import { offersGoogleRetry } from '../../utils/placeSource'
import { safeHexColor } from '../../utils/safeColor'
import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
import { NumericInput } from '../shared/NumericInput'
import { formatDistance } from '../../utils/units'
import { PlacesSession } from '../../utils/placesSession'
import ServiceStopSection from '../Roadtrip/ServiceStopSection'
import { DEFAULT_SERVICE_KIND, serviceStopChoice, type ServiceStopMode } from '../Roadtrip/manualStop'
import { STOP_KIND_BY_KEY, type StopKind } from '../Roadtrip/stopKinds'
@@ -46,7 +43,6 @@ import {
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
// category_id is normalised, plus any files chosen before the place existed.
@@ -92,24 +88,6 @@ interface PlaceFormModalProps {
}
/**
* One row of the typed-ahead list, as the server sends it.
*
* `source`, `lat` and `lng` are optional because not every index fills them:
* Google answers with neither, and the mark falls back to the name the whole
* list carries. `place` is only on a plugin's row, which brings its whole place
* along because no details lookup knows a plugin id (#2221).
*/
type Suggestion = {
placeId: string
mainText: string
secondaryText: string
source?: string
lat?: number
lng?: number
place?: Record<string, unknown>
}
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
function SourceBadge({ label }: { label: string | null }) {
if (!label) return null
@@ -127,36 +105,25 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} = props
// Hidden while the addon is off, because the kinds only mean anything to the road trip
// rail: on an instance without it they would be six labels that change nothing.
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
/** What answered the last full search. Only a fallback: a merged list carries the source per place. */
const [searchSource, setSearchSource] = useState<string>('')
// The list on screen answers "what is near the pin" rather than a typed query (#976).
const [nearbyList, setNearbyList] = useState(false)
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
* state because nothing renders from it and a pick must read the value that
* belonged to the list, not a value a re-render replaced.
*/
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
const toast = useToast()
const { t, locale } = useTranslation()
const { placesEnrichEnabled } = useAuthStore()
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget'))
const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem)
const {
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
adoptDescription, warnIfDuplicate,
} = usePlaceForm({ t, toast, canUploadFiles })
// The index the picked place came from, as its row named it, shown as a pill
// in the head band. Null for a place typed by hand or taken from the saved list.
const [pickedSource, setPickedSource] = useState<string | null>(null)
// What the detail column is describing. Null until the user picks a result.
const [detailsSelection, setDetailsSelection] = useState<PlaceDetailsSelection | null>(null)
// Which fields the last picked search result wrote. Anything in here belongs
// to that place and goes when another is picked; anything outside it is the
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set())
// Whether the category was preselected from a search result rather than chosen, so
// the next pick may replace it and a hand choice is never overwritten (#2282).
const categoryGuessedRef = useRef(false)
@@ -165,10 +132,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
categoryGuessedRef.current = id != null
return id != null ? String(id) : ''
}
const [pendingFiles, setPendingFiles] = useState([])
// The query the last full search found nothing for (#2472): the cue to add the
// place by hand instead of a silent empty list.
const [emptySearch, setEmptySearch] = useState<string | null>(null)
// The hours the details column looked up, as a week the contact block can take over (#2472).
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
/**
@@ -178,58 +141,23 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
*/
const [serviceStopLeg, setServiceStopLeg] = useState('')
const fileRef = useRef(null)
const [acSuggestions, setAcSuggestions] = useState<Suggestion[]>([])
// Which index answered the last keystroke, for the rows that do not say so
// themselves. Google and the OpenStreetMap fallback each answer from one
// place; the index path answers from two at once and marks every row.
const [acSource, setAcSource] = useState<string>('')
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
// Counts the closings of this dialog, so an answer still on its way when it
// closed can tell that the opening it was asked for is over. A counter rather
// than an abort, because the full search takes no signal.
const searchEpochRef = useRef(0)
// Ties one search's keystrokes and its details lookup into a single Google
// billing session (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, locale } = useTranslation()
// Place names in the language the user picked for them, the app's otherwise (#1799).
const language = usePlaceLanguage()
const { placesEnrichEnabled } = useAuthStore()
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget'))
const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem)
// Set right before submit when the user clicked create/edit expense — the
// place has to exist before an expense can point at it (see ReservationModal).
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const newCategory = useNewCategory({
variant: 'dialog',
create: category => onCategoryCreated?.(category),
onCreated: categoryId => setForm(prev => ({ ...prev, category_id: categoryId })),
t,
toast,
})
useEffect(() => {
categoryGuessedRef.current = false
let next: PlaceFormData
if (place) {
// Times are stored per day-assignment, not on the pool place. When an
// assignment is in context (itinerary edit, or a single-assignment pool
// edit) read the times off its embedded place; fall back to the place prop.
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
const timeSource = assignment?.place ?? place
setForm({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat != null ? String(place.lat) : '',
lng: place.lng != null ? String(place.lng) : '',
category_id: place.category_id != null ? String(place.category_id) : '',
place_time: timeSource.place_time || '',
end_time: timeSource.end_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
next = placeEditForm(place, assignment, {
// Shown in the contact block now (#2472), so an edit carries what is there.
phone: place.phone || '',
email: place.email || '',
@@ -239,20 +167,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// duration_minutes deliberately stays out: how long a stay takes belongs to the
// rail's own dialog, and sending it from here would overwrite what was set there.
stop_type: place.stop_type ?? null,
// The day-specific note rides only with an assignment in context (#2163);
// otherwise the key stays absent so submit never sends a notes write.
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
})
} else if (prefillCoords) {
setForm({
...DEFAULT_FORM,
lat: String(prefillCoords.lat),
lng: String(prefillCoords.lng),
name: prefillCoords.name || '',
address: prefillCoords.address || '',
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
next = prefillForm(prefillCoords, {
category_id: guessedCategory(prefillCoords),
stop_type: prefillCoords.stop_type ?? null,
duration_minutes: prefillCoords.duration_minutes,
@@ -269,29 +186,19 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// nothing selected falls back to the constant. The dwell follows the kind, the
// same way picking one by hand moves it.
const kind = serviceStop.defaultKind ?? DEFAULT_SERVICE_KIND
setForm({
next = {
...DEFAULT_FORM,
stop_type: kind,
duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes,
})
}
} else {
setForm(DEFAULT_FORM)
next = DEFAULT_FORM
}
// A fresh dialog owns nothing yet. The exception is a POI tapped on the map
// or a right-click place: those arrive prefilled from a place, so the same
// fields belong to it and a later search pick may clear them. An existing
// place being edited is the opposite — everything on that form came out of
// the database and none of it is a search result's to drop.
autoFilledRef.current = new Set(
!place && prefillCoords
? (['name', 'address', 'lat', 'lng', 'website', 'phone', 'osm_id'] as ResultField[]).filter(
(field) => !!prefillCoords[field as keyof typeof prefillCoords],
)
: [],
)
setPendingFiles([])
// The column follows whatever the dialog was opened with, not only a search
// pick: a POI tapped on the map and a right-click place arrive as
// prefillCoords, and editing an existing place arrives as `place`.
openForm(next, place, prefillCoords)
setServiceStopLeg('')
setDuplicateWarning(null)
setPickedSource(null)
// A fresh dialog owns no intention either. The ref is armed by a click on the Costs
// section and spent by the save that follows it; a save that never happened leaves it
@@ -299,64 +206,72 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// to. In service-stop mode that opening has no Costs section at all, so the editor
// would arrive out of nowhere, on a petrol stop, filed as an activity.
expenseIntentRef.current = null
// The column follows whatever the dialog was opened with, not only a search
// pick: a POI tapped on the map and a right-click place arrive as
// prefillCoords, and editing an existing place arrives as `place`. Without
// this the column kept showing the previous place's pictures and facts,
// because only handleSelectMapsResult ever set it.
if (place && place.lat != null && place.lng != null) {
setDetailsSelection({
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat: Number(place.lat),
lng: Number(place.lng),
name: place.name || '',
})
} else if (prefillCoords) {
setDetailsSelection({
placeId: prefillCoords.osm_id || undefined,
lat: prefillCoords.lat,
lng: prefillCoords.lng,
name: prefillCoords.name || '',
})
} else {
setDetailsSelection(null)
}
// dayAssignments is a fresh array each render; read it at open-time only and
// re-run on identity changes (place/assignmentId/open), not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place, prefillCoords, isOpen, assignmentId])
// The planner keeps this dialog mounted while it is closed (the shell only
// stops drawing it), so everything the search block holds would greet the next
// opening: the last query, its list, the Google line offering that list's
// query again, suggestions still on their way. A pick from that list writes
// over the place being edited, whichever place that is by then. Reset when
// it closes, in an effect of its own: the one above also reruns while the
// dialog is open, when a right-click's reverse lookup fills prefillCoords,
// and must not wipe a search the user has started since.
useEffect(() => {
if (isOpen) return
searchEpochRef.current += 1
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
acAbortRef.current?.abort()
placesSessionRef.current.end()
searchMetaRef.current = null
acMetaRef.current = null
setMapsSearch('')
setMapsResults([])
setSearchSource('')
setAcSuggestions([])
setAcSource('')
setAcHighlight(-1)
setIsSearchingMaps(false)
}, [isOpen])
const places = useTripStore((s) => s.places)
// Where the trip is happening — the hint that tells the search which of a
// thousand identically named places is meant. Autocomplete wants a box, the
// search wants a point; useLocationBias derives both from the same places.
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
const { autocomplete, sourceLabel } = usePlaceSuggestions()
// search wants a point; both are derived from the same places.
const { box: locationBias } = useLocationBias()
/**
* The one point every pick flows through, so the detail column hangs here: a
* search row, a suggestion's place, or a place from the saved-place picker.
* `label` is the name the picked row's badge carried; a saved place names none.
*/
const applyPickedPlace = (result: Record<string, unknown>, label: string | null) => {
setForm(prev => {
const merged = mergeResult(prev, result, autoFilledRef.current)
if (prev.category_id && !categoryGuessedRef.current) return merged
return { ...merged, category_id: guessedCategory(result) }
})
setPickedSource(label)
// A new pick drops whatever hero image belonged to the previous place.
const lat = Number(result.lat)
const lng = Number(result.lng)
if (Number.isFinite(lat) && Number.isFinite(lng)) {
setDetailsSelection({
placeId: (result.google_place_id || result.amap_poi_id || result.osm_id || undefined) as string | undefined,
lat,
lng,
name: (result.name as string) || '',
// Hand the record along: the server needs the same OSM tags, and
// looking them up again costs an Overpass round trip it can skip.
details: result,
})
setForm(prev => ({ ...prev, image_url: undefined }))
}
}
const search = usePlaceSearch({
variant: 'dialog',
locationBias,
t,
toast,
// The planner keeps this dialog mounted while it is closed (the shell only
// stops drawing it), so the search block is reset when it closes, apart from
// the opening effect above: that one also reruns while the dialog is open, when
// a right-click's reverse lookup fills prefillCoords, and must not wipe a
// search the user has started since.
open: isOpen,
onPlace: applyPickedPlace,
onSuggestionName: name => setForm(prev => ({ ...prev, name })),
onMapLink: resolved => {
setForm(prev => ({
...prev,
name: resolved.name || prev.name,
address: resolved.address || prev.address,
lat: String(resolved.lat),
lng: String(resolved.lng),
google_ftid: resolved.google_ftid || prev.google_ftid,
}))
// The fields are the link's now, not the last picked result's.
setPickedSource(null)
},
})
/**
* What a stop on a drive might be a second copy of, said while the form is being filled.
@@ -371,290 +286,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
return findDuplicatePlace(form, places, { byName: false, byOsmId: true })?.name ?? null
}, [serviceStop, place, form, places])
// Autocomplete fetch — aborts any in-flight request before starting a new one
const fetchSuggestions = useCallback(async (query: string) => {
if (query.length < 2 || isMapUrl(query)) {
setAcSuggestions([])
setAcHighlight(-1)
return
}
acAbortRef.current?.abort()
const controller = new AbortController()
acAbortRef.current = controller
try {
const result = await autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcSource(result.source || '')
setAcHighlight(-1)
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
if (err instanceof Error && err.name === 'CanceledError') return // axios abort
console.error('Autocomplete failed:', err)
setAcSuggestions([])
}
}, [autocomplete, language, locationBias])
// Debounce effect — only watches mapsSearch
useEffect(() => {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
const trimmed = mapsSearch.trim()
if (trimmed.length < 2 || isMapUrl(trimmed)) {
// A list still on its way belongs to a query that is gone.
acAbortRef.current?.abort()
setAcSuggestions([])
setAcHighlight(-1)
placesSessionRef.current.end()
return
}
acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300)
return () => {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
}
}, [mapsSearch, fetchSuggestions])
const handleChange = (field: string, value: string) => {
// Typed by hand, so the next pick must not clear it.
autoFilledRef.current.delete(field as ResultField)
if (field === 'category_id') categoryGuessedRef.current = false
setForm(prev => ({ ...prev, [field]: value }))
changeField(field, value)
}
const handleMapsSearch = async (provider?: 'google') => {
// The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim()
if (!trimmed) return
const epoch = searchEpochRef.current
setIsSearchingMaps(true)
try {
// A pasted Google Maps or Amap link resolves server-side into a place
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (epoch !== searchEpochRef.current) return
if (resolved.lat && resolved.lng) {
setForm(prev => ({
...prev,
name: resolved.name || prev.name,
address: resolved.address || prev.address,
lat: String(resolved.lat),
lng: String(resolved.lng),
google_ftid: resolved.google_ftid || prev.google_ftid,
}))
setMapsResults([])
setMapsSearch('')
// The fields are the link's now, not the last picked result's.
setPickedSource(null)
toast.success(t('places.urlResolved'))
return
}
}
const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider)
if (epoch !== searchEpochRef.current) return
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setNearbyList(false)
setMapsResults(result.places || [])
setEmptySearch((result.places || []).length === 0 ? trimmed : null)
setSearchSource(result.source || '')
} catch (err: unknown) {
if (epoch !== searchEpochRef.current) return
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
}
}
// What is around the pin the form holds (#976): the list a search would show,
// nearest first. Not logged as a search pick, because no query was typed.
// What is around the pin the form holds (#976).
const pin = formPin(form)
const handleNearby = async () => {
if (!pin) return
const epoch = searchEpochRef.current
setIsSearchingMaps(true)
setAcSuggestions([])
try {
const result = await mapsApi.nearby(pin.lat, pin.lng, language)
if (epoch !== searchEpochRef.current) return
searchMetaRef.current = null
setNearbyList(true)
setMapsResults(result.places || [])
setSearchSource(result.source || '')
if (!result.places?.length) toast.info(t('places.nearbyNone'))
} catch (err: unknown) {
if (epoch !== searchEpochRef.current) return
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
if (epoch === searchEpochRef.current) setIsSearchingMaps(false)
}
}
/**
* `pick` is present only when the click came from a ranked list. The
* collection picker and the autocomplete detour reach this function with a
* place that was never ranked against a query, and a made-up rank would be
* worse than no row at all.
*/
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
setForm(prev => {
const merged = mergeResult(prev, result, autoFilledRef.current)
if (prev.category_id && !categoryGuessedRef.current) return merged
return { ...merged, category_id: guessedCategory(result) }
})
// The same name the row's badge carried. A suggestion's pick overrides it
// with its own row's (handleSelectSuggestion); a saved place names none.
setPickedSource(sourceLabel(result, pick?.mode === 'search' ? searchSource : ''))
// The one point every pick flows through, so the detail column hangs here.
// A new pick drops whatever hero image belonged to the previous place.
const lat = Number(result.lat)
const lng = Number(result.lng)
if (Number.isFinite(lat) && Number.isFinite(lng)) {
setDetailsSelection({
placeId: result.google_place_id || result.amap_poi_id || result.osm_id || undefined,
lat,
lng,
name: result.name || '',
// Hand the record along: the server needs the same OSM tags, and
// looking them up again costs an Overpass round trip it can skip.
details: result,
})
setForm(prev => ({ ...prev, image_url: undefined }))
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
if (meta) {
recordPlacePick({
query: meta.query,
lang: language,
// The bias the search actually ran under is a box around the trip's
// existing places; the corpus stores its centre, which is what an
// evaluation needs to bias its own index the same way.
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: result.name || '',
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: result.google_place_id || result.amap_poi_id || result.osm_id || null,
})
}
}
}
setMapsResults([])
setMapsSearch('')
}
const handleSelectSuggestion = async (suggestion: Suggestion) => {
// Read before the list is cleared: this is the rank the user saw.
const acPick = corePickRank(acSuggestions, suggestion)
acAbortRef.current?.abort()
setAcSuggestions([])
setAcHighlight(-1)
const previousSearch = mapsSearch
const epoch = searchEpochRef.current
setMapsSearch('')
setForm(prev => ({ ...prev, name: suggestion.mainText }))
setIsSearchingMaps(true)
try {
// The details lookup is a fragile second hop — it can fail when the
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
// or on any upstream error. Treat a missing/coordinate-less place as a
// miss and fall back to the reliable text-search path the search button
// uses (its results already carry coordinates), so dropdown items stay
// clickable instead of dead-ending on "Place search failed". (#1192)
let place: Record<string, unknown> | null = suggestion.place ?? null
if (!place) {
try {
// Spends the session the suggestions opened, so Google bills the search
// once rather than per keystroke.
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
if (result.place && result.place.lat != null && result.place.lng != null) {
place = result.place
}
} catch (err) {
console.error('Failed to fetch place details:', err)
}
}
// Closed while the details were on their way: the pick belongs to an
// opening that is over, and the fallback search below is not worth a
// request nobody will see.
if (epoch !== searchEpochRef.current) return
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's rows carry no address; their second line is the name
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
// is not a question anybody asked, and its first answer would be
// whatever the index made of it — a different place, chosen silently.
// The suggestion already knows where it is, so use that.
place = {
name: suggestion.mainText,
address: '',
lat: suggestion.lat,
lng: suggestion.lng,
osm_id: suggestion.placeId,
source: 'openstreetmap',
}
}
if (!place) {
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(query, language, locationBiasPoint)
if (epoch !== searchEpochRef.current) return
place = search.places?.[0] ?? null
}
if (place) {
handleSelectMapsResult(place, acPick && { mode: 'autocomplete', ...acPick })
// Named after the row that was clicked, whatever the lookup behind it answered from.
setPickedSource(sourceLabel(suggestion, acSource))
} else {
setMapsSearch(previousSearch)
toast.error(t('places.mapsSearchError'))
}
} catch (err) {
if (epoch !== searchEpochRef.current) return
console.error('Place suggestion lookup failed:', err)
setMapsSearch(previousSearch)
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
// Closing already ended this session; ending it again here would cut off
// the one a new opening may have started meanwhile.
if (epoch === searchEpochRef.current) {
setIsSearchingMaps(false)
placesSessionRef.current.end()
}
}
}
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (acSuggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setAcHighlight(prev => (prev + 1) % acSuggestions.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (acHighlight >= 0) {
void handleSelectSuggestion(acSuggestions[acHighlight])
} else {
setAcSuggestions([])
void handleMapsSearch()
}
} else if (e.key === 'Escape') {
// Spent on the list: the dialog leaves an Escape that was already
// handled alone, so only the next one closes it.
e.preventDefault()
setAcSuggestions([])
setAcHighlight(-1)
}
} else if (e.key === 'Enter') {
e.preventDefault()
void handleMapsSearch()
}
}
const handleNearby = () => search.searchNearby(pin)
/**
* The kind of stop, and with it how long that kind usually takes.
@@ -665,48 +304,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
*/
const handleStopKind = useCallback((kind: RoadtripStopType) => {
setForm(prev => ({ ...prev, stop_type: kind, duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes }))
}, [])
}, [setForm])
const handleStopMinutes = useCallback((minutes: number) => {
setForm(prev => ({ ...prev, duration_minutes: minutes }))
}, [])
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' }) // theme-lint-disable: the stored default of a new category, not a colour of this dialog
if (cat) setForm(prev => ({ ...prev, category_id: String(cat.id) }))
setNewCategoryName('')
setShowNewCategory(false)
} catch {
toast.error(t('places.categoryCreateError'))
}
}
const handleFileAdd = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
setPendingFiles(prev => [...prev, ...files])
e.target.value = ''
}
const handleRemoveFile = (idx: number) => {
setPendingFiles(prev => prev.filter((_, i) => i !== idx))
}
// Paste support for files/images, anywhere in the dialog
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
if (file) setPendingFiles(prev => [...prev, file])
return
}
}
}
}, [setForm])
const hasTimeError = !!place && endsBeforeStart(form.place_time, form.end_time)
@@ -729,27 +331,16 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// save on it, only noted it. So the note is shown beside the name while the form is
// being filled (see serviceStopDuplicate), which is the earlier word anyway, and the
// press that saves is the press that saves.
if (!place && !serviceStop && !duplicateWarning) {
const dup = findDuplicatePlace(form, places)
if (dup) {
const dupName = dup.name || form.name
setDuplicateWarning(dupName)
toast.warning(t('places.duplicateExists', { name: dupName }))
// Nothing was saved, so an expense intent from a previous click is stale, and
// the next plain Save would otherwise open a Costs editor out of nowhere.
expenseIntentRef.current = null
return
}
if (!place && !serviceStop && !duplicateWarning && warnIfDuplicate(places)) {
// Nothing was saved, so an expense intent from a previous click is stale, and
// the next plain Save would otherwise open a Costs editor out of nowhere.
expenseIntentRef.current = null
return
}
setIsSaving(true)
try {
const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null
const payload = {
...form,
lat,
lng,
category_id: form.category_id || null,
const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
const payload = placeFormPayload(form, pendingFiles, ctxAssignment, (lat, lng) => ({
// An explicit null is how a stop stops being a fuel stop; the service reads it
// that way rather than as "leave alone", which is what a missing key means.
stop_type: form.stop_type || null,
@@ -765,16 +356,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
...(serviceStop
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
: {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
}
// #2163: the per-assignment note only travels when an assignment is in
// context AND the value actually changed — an untouched note must not
// produce a PUT (a legacy note longer than the textarea would otherwise
// be re-sent on every unrelated save).
const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
delete payload.assignment_notes
}
}))
const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the
@@ -815,49 +397,50 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
isMobile,
collectionsEnabled,
form,
setForm,
mapsSearch,
setMapsSearch,
mapsResults,
isSearchingMaps,
newCategoryName,
setNewCategoryName,
showNewCategory,
setShowNewCategory,
mapsSearch: search.query,
setMapsSearch: search.setQuery,
mapsResults: search.results,
isSearchingMaps: search.searching,
newCategoryName: newCategory.name,
setNewCategoryName: newCategory.setName,
showNewCategory: newCategory.open,
setShowNewCategory: newCategory.setOpen,
isSaving,
pendingFiles,
emptySearch, setEmptySearch,
emptySearch: search.emptySearch, setEmptySearch: search.setEmptySearch,
detailsHours, setDetailsHours,
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acSuggestions: search.suggestions,
setAcSuggestions: search.setSuggestions,
acSource: search.acSource,
searchSource: search.searchSource,
acHighlight: search.highlight,
setAcHighlight: search.setHighlight,
t,
language,
language: search.language,
locale,
timeFormat,
googleAnswers,
googleAnswers: search.googleAnswers,
placesEnrichEnabled,
canUploadFiles,
locationBias,
fetchSuggestions,
sourceLabel,
fetchSuggestions: search.fetchSuggestions,
sourceLabel: search.sourceLabel,
handleChange,
handleMapsSearch,
handleMapsSearch: search.runSearch,
handleNearby,
pin,
nearbyList,
nearbyList: search.nearbyList,
distanceUnit,
handleSelectMapsResult,
handleSelectSuggestion,
handleSearchKeyDown,
handleCreateCategory,
handleFileAdd,
handleRemoveFile,
handleSelectMapsResult: search.pickPlace,
handleSelectSuggestion: search.selectSuggestion,
handleSearchKeyDown: search.handleKeyDown,
handleCreateCategory: newCategory.submit,
handleFileAdd: addFilesFromInput,
handleRemoveFile: removeFile,
handlePaste,
pickImage,
adoptDescription,
hasTimeError,
handleSubmit,
duplicateWarning,
@@ -902,7 +485,7 @@ type Translate = (key: string, params?: Record<string, string | number>) => stri
export default function PlaceFormModal(props: PlaceFormModalProps) {
const S = usePlaceFormModal(props)
const {
isOpen, onClose, place, prefillCoords, categories, isMobile, collectionsEnabled, form, setForm,
isOpen, onClose, place, prefillCoords, categories, isMobile, collectionsEnabled, form,
mapsSearch, setMapsSearch, mapsResults, isSearchingMaps, acSuggestions, setAcSuggestions, acSource,
searchSource, acHighlight, setAcHighlight, t, language, googleAnswers, placesEnrichEnabled,
canUploadFiles, fetchSuggestions, sourceLabel, handleChange, handleMapsSearch, handleSelectMapsResult,
@@ -1021,8 +604,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<PlaceDetailsColumn
selection={detailsSelection}
selectedImageUrl={form.image_url}
onPickImage={(url) => setForm(prev => ({ ...prev, image_url: url ?? undefined }))}
onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))}
onPickImage={S.pickImage}
onAdoptDescription={S.adoptDescription}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()}
language={language}
@@ -0,0 +1,244 @@
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop
// dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet').
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Category, Place } from '../../types';
import { DEFAULT_FORM } from './PlaceFormModal.helpers';
import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm';
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${JSON.stringify(params)}` : key;
const makeToast = () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() });
let toast: ReturnType<typeof makeToast>;
beforeEach(() => {
toast = makeToast();
});
function setup(canUploadFiles = true) {
return renderHook(() => usePlaceForm({ t, toast, canUploadFiles }));
}
const PLACE = {
id: 5,
name: 'Louvre',
lat: 48.86,
lng: 2.33,
google_place_id: 'g-louvre',
amap_poi_id: null,
osm_id: null,
} as unknown as Place;
function file(name: string, type: string) {
return new File(['x'], name, { type });
}
function pasteEvent(items: { type: string; file: File | null }[]) {
const preventDefault = vi.fn();
const event = {
preventDefault,
clipboardData: { items: items.map((item) => ({ type: item.type, getAsFile: () => item.file })) },
} as unknown as React.ClipboardEvent;
return { event, preventDefault };
}
describe('usePlaceForm', () => {
it('FE-PLANNER-PLFORM-001: starts on the blank form with nothing attached, warned or described', () => {
const { result } = setup();
expect(result.current.form).toEqual(DEFAULT_FORM);
expect(result.current.pendingFiles).toEqual([]);
expect(result.current.duplicateWarning).toBeNull();
expect(result.current.detailsSelection).toBeNull();
expect(result.current.isSaving).toBe(false);
expect(result.current.autoFilledRef.current.size).toBe(0);
});
it('FE-PLANNER-PLFORM-002: opening on a place being edited owns no field and describes that place', () => {
const { result } = setup();
const next = { ...DEFAULT_FORM, name: 'Louvre' };
act(() => result.current.openForm(next, PLACE, null));
expect(result.current.form).toBe(next);
expect(result.current.autoFilledRef.current.size).toBe(0);
expect(result.current.detailsSelection).toEqual({ placeId: 'g-louvre', lat: 48.86, lng: 2.33, name: 'Louvre' });
});
it('FE-PLANNER-PLFORM-003: opening on a map POI owns the fields it filled and describes the POI', () => {
const { result } = setup();
const prefill = { lat: 1, lng: 2, name: 'Cafe', website: 'https://cafe.example', osm_id: 'N9' };
act(() => result.current.openForm(DEFAULT_FORM, null, prefill));
expect([...result.current.autoFilledRef.current].sort()).toEqual(['lat', 'lng', 'name', 'osm_id', 'website']);
expect(result.current.detailsSelection).toEqual({ placeId: 'N9', lat: 1, lng: 2, name: 'Cafe' });
});
it('FE-PLANNER-PLFORM-004: a fresh opening drops the files and the warning the last one left', () => {
const { result } = setup();
act(() => result.current.addFiles([file('a.pdf', 'application/pdf')]));
act(() => result.current.handleChange('name', 'Louvre'));
act(() => {
result.current.warnIfDuplicate([{ name: 'louvre' }]);
});
expect(result.current.duplicateWarning).toBe('louvre');
act(() => result.current.openForm(DEFAULT_FORM, null, null));
expect(result.current.pendingFiles).toEqual([]);
expect(result.current.duplicateWarning).toBeNull();
expect(result.current.detailsSelection).toBeNull();
});
it('FE-PLANNER-PLFORM-005: a field typed by hand is no longer the last pick to clear', () => {
const { result } = setup();
act(() => result.current.openForm(DEFAULT_FORM, null, { lat: 1, lng: 2, name: 'Cafe' }));
act(() => result.current.handleChange('name', 'My cafe'));
expect(result.current.form.name).toBe('My cafe');
expect(result.current.autoFilledRef.current.has('name')).toBe(false);
expect(result.current.autoFilledRef.current.has('lat')).toBe(true);
});
it('FE-PLANNER-PLFORM-006: files are added from the input, which is cleared, and removed by index', () => {
const { result } = setup();
const a = file('a.pdf', 'application/pdf');
const b = file('b.png', 'image/png');
const target = { files: [a, b], value: 'C:\\fakepath\\a.pdf' };
act(() => result.current.addFilesFromInput({ target } as unknown as React.ChangeEvent<HTMLInputElement>));
expect(result.current.pendingFiles).toEqual([a, b]);
expect(target.value).toBe('');
act(() => result.current.removeFile(0));
expect(result.current.pendingFiles).toEqual([b]);
});
it('FE-PLANNER-PLFORM-007: a pasted picture or PDF becomes an attachment, the first one only', () => {
const { result } = setup();
const img = file('shot.png', 'image/png');
const { event, preventDefault } = pasteEvent([
{ type: 'text/plain', file: null },
{ type: 'image/png', file: img },
{ type: 'application/pdf', file: file('x.pdf', 'application/pdf') },
]);
act(() => result.current.handlePaste(event));
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(result.current.pendingFiles).toEqual([img]);
});
it('FE-PLANNER-PLFORM-008: a member who may not upload pastes text as text', () => {
const { result } = setup(false);
const { event, preventDefault } = pasteEvent([{ type: 'image/png', file: file('shot.png', 'image/png') }]);
act(() => result.current.handlePaste(event));
expect(preventDefault).not.toHaveBeenCalled();
expect(result.current.pendingFiles).toEqual([]);
});
it('FE-PLANNER-PLFORM-009: the details column picks the image and hands over its description', () => {
const { result } = setup();
act(() => result.current.pickImage('https://img.example/1.jpg'));
expect(result.current.form.image_url).toBe('https://img.example/1.jpg');
act(() => result.current.pickImage(null));
expect(result.current.form.image_url).toBeUndefined();
act(() => result.current.adoptDescription('A museum'));
expect(result.current.form.description).toBe('A museum');
});
it('FE-PLANNER-PLFORM-010: a likely duplicate warns once by name; none lets the save go on', () => {
const { result } = setup();
act(() => result.current.handleChange('name', 'Louvre'));
let warned = true;
act(() => {
warned = result.current.warnIfDuplicate([{ name: 'Orsay' }]);
});
expect(warned).toBe(false);
expect(toast.warning).not.toHaveBeenCalled();
act(() => {
warned = result.current.warnIfDuplicate([{ name: 'Louvre Museum' }, { name: ' louvre ' }]);
});
expect(warned).toBe(true);
expect(result.current.duplicateWarning).toBe(' louvre ');
expect(toast.warning).toHaveBeenCalledWith('places.duplicateExists:{"name":" louvre "}');
});
});
function category(id: number): Category {
return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category;
}
describe('useNewCategory', () => {
function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) {
const onCreated = vi.fn();
const hook = renderHook(() => useNewCategory({ ...options, onCreated, t, toast } as NewCategoryOptions));
return { ...hook, onCreated };
}
it('FE-PLANNER-PLFORM-011: a blank name creates nothing', async () => {
const create = vi.fn(async () => category(1));
const { result } = setupCategory({ variant: 'sheet', create });
act(() => result.current.setName(' '));
await act(async () => {
await result.current.submit();
});
expect(create).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLFORM-012: the desktop sends the name as typed, picks the new category and closes', async () => {
const create = vi.fn(async () => category(7));
const { result, onCreated } = setupCategory({ variant: 'dialog', create });
act(() => {
result.current.setOpen(true);
result.current.setName(' Food ');
});
await act(async () => {
await result.current.submit();
});
expect(create).toHaveBeenCalledWith({ name: ' Food ', color: '#6366f1', icon: 'MapPin' });
expect(onCreated).toHaveBeenCalledWith('7');
expect(result.current.name).toBe('');
expect(result.current.open).toBe(false);
});
it('FE-PLANNER-PLFORM-013: a desktop create that hands nothing back picks nothing but still closes', async () => {
const create = vi.fn(() => undefined);
const { result, onCreated } = setupCategory({ variant: 'dialog', create });
act(() => {
result.current.setOpen(true);
result.current.setName('Food');
});
await act(async () => {
await result.current.submit();
});
expect(onCreated).not.toHaveBeenCalled();
expect(result.current.open).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLFORM-014: the phone trims the name, ignores a second tap while saving and toasts a failure', async () => {
let finish: (c: Category) => void = () => {};
const create = vi.fn(() => new Promise<Category>((resolve) => (finish = resolve)));
const { result, onCreated } = setupCategory({ variant: 'sheet', create });
act(() => result.current.setName(' Food '));
let first: Promise<void> = Promise.resolve();
act(() => {
first = result.current.submit();
});
expect(result.current.saving).toBe(true);
await act(async () => {
await result.current.submit();
});
expect(create).toHaveBeenCalledTimes(1);
expect(create).toHaveBeenCalledWith({ name: 'Food', color: '#6366f1', icon: 'MapPin' });
await act(async () => {
finish(category(3));
await first;
});
expect(onCreated).toHaveBeenCalledWith('3');
expect(result.current.saving).toBe(false);
create.mockImplementation(() => Promise.reject(new Error('nope')));
act(() => result.current.setName('Bar'));
await act(async () => {
await result.current.submit();
});
expect(toast.error).toHaveBeenCalledWith('places.categoryCreateError');
expect(result.current.name).toBe('Bar');
});
});
@@ -0,0 +1,184 @@
import type React from 'react';
import { useRef, useState } from 'react';
import type { useTranslation } from '../../i18n';
import type { Category } from '../../types';
import type { useToast } from '../shared/Toast';
import type { PlaceDetailsSelection } from './PlaceDetailsColumn';
import {
DEFAULT_FORM,
duplicateName,
openingAutoFilled,
openingDetailsSelection,
type PlaceFormData,
type PlacePrefill,
type ResultField,
} from './PlaceFormModal.helpers';
type Translate = ReturnType<typeof useTranslation>['t'];
type Toast = ReturnType<typeof useToast>;
export interface PlaceFormOptions {
t: Translate;
toast: Toast;
/** Files and pictures pasted into the form become attachments only for a member who may upload. */
canUploadFiles: boolean;
}
/**
* The place add/edit form's own state: the one logic path behind the desktop dialog and
* the phone sheet, which render their own markup over it and keep their own openings,
* search and save flow. It holds the fields, which of them the last picked search result
* wrote (see mergeResult), the files chosen before the place exists, what the details
* column describes, and the duplicate warning a first save of a new place may raise.
*/
export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
const [form, setForm] = useState<PlaceFormData>(DEFAULT_FORM);
// Which fields the last picked search result wrote. Anything in here belongs
// to that place and goes when another is picked; anything outside it is the
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set());
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
// What the details column is describing. Null until there is a place to describe.
const [detailsSelection, setDetailsSelection] = useState<PlaceDetailsSelection | null>(null);
/**
* A fresh opening, on the form the caller built for it: the fields that opening owns,
* what the details column follows (otherwise it kept showing the previous place's
* pictures and facts), no files and no warning left from the last one.
*/
const openForm = (
next: PlaceFormData,
place: Parameters<typeof openingDetailsSelection>[0],
prefill: PlacePrefill | null | undefined
) => {
setForm(next);
autoFilledRef.current = openingAutoFilled(place, prefill);
setDetailsSelection(openingDetailsSelection(place, prefill));
setPendingFiles([]);
setDuplicateWarning(null);
};
const handleChange = (field: string, value: string) => {
// Typed by hand, so the next pick must not clear it.
autoFilledRef.current.delete(field as ResultField);
setForm((prev) => ({ ...prev, [field]: value }));
};
const addFiles = (files: File[]) => setPendingFiles((prev) => [...prev, ...files]);
/** The file input's pick; the input is cleared so the same file can be chosen again. */
const addFilesFromInput = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
e.target.value = '';
};
const removeFile = (idx: number) => setPendingFiles((prev) => prev.filter((_, i) => i !== idx));
// Clipboard images/PDFs from any focused field become pending attachments.
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadFiles) return;
for (const item of Array.from(e.clipboardData?.items || [])) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault();
const file = item.getAsFile();
if (file) setPendingFiles((prev) => [...prev, file]);
return;
}
}
};
/** A hero image picked in the details column, or none. */
const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
/** The details column's description, taken over into the form. */
const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text }));
/**
* #1152: the first save of a new place warns when the trip seems to have it already;
* a second press with the warning showing is the explicit "add anyway". True when it
* warned, and the save stops there.
*/
const warnIfDuplicate = (places: Parameters<typeof duplicateName>[1]) => {
const name = duplicateName(form, places);
if (!name) return false;
setDuplicateWarning(name);
toast.warning(t('places.duplicateExists', { name }));
return true;
};
return {
form,
setForm,
autoFilledRef,
pendingFiles,
addFiles,
addFilesFromInput,
removeFile,
handlePaste,
isSaving,
setIsSaving,
duplicateWarning,
detailsSelection,
setDetailsSelection,
openForm,
handleChange,
pickImage,
adoptDescription,
warnIfDuplicate,
};
}
const NEW_CATEGORY_COLOR = '#6366f1'; // theme-lint-disable: a new category's stored default, not a colour of the form
/** What a new category is created with, besides its name. */
type NewCategory = { name: string; color: string; icon: string };
export type NewCategoryOptions = {
t: Translate;
toast: Toast;
/** The new category's id, once it exists: it is picked straight away. */
onCreated: (categoryId: string) => void;
} & (
| {
/** Desktop: the name goes as typed, and a create that hands nothing back picks nothing. */
variant: 'dialog';
create: (category: NewCategory) => Promise<Category> | undefined;
}
| {
/** Phone: the name goes trimmed, and a second create waits until the first is done. */
variant: 'sheet';
create: (category: NewCategory) => Promise<Category>;
}
);
/**
* The inline "new category" of the place form, for both shells: a name typed where the
* category pills are, created with the default colour and icon and picked at once.
*/
export function useNewCategory({ t, toast, onCreated, variant, create }: NewCategoryOptions) {
const sheet = variant === 'sheet';
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const submit = async () => {
if (!name.trim() || (sheet && saving)) return;
if (sheet) setSaving(true);
try {
const cat = await create({ name: sheet ? name.trim() : name, color: NEW_CATEGORY_COLOR, icon: 'MapPin' });
// The phone's create always hands one back; reading it unguarded fails into the catch as before.
if (sheet || cat) onCreated(String((cat as Category).id));
setName('');
setOpen(false);
} catch {
toast.error(t('places.categoryCreateError'));
} finally {
if (sheet) setSaving(false);
}
};
return { open, setOpen, name, setName, saving, submit };
}
@@ -0,0 +1,453 @@
// FE-PLANNER-PLSEARCH-001 to FE-PLANNER-PLSEARCH-022: the maps search behind the desktop
// place dialog (variant 'dialog') and the phone sheet's search row ('sheet').
import { act, renderHook, waitFor } from '@testing-library/react';
import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores } from '../../../tests/helpers/store';
import { mapsApi } from '../../api/client';
import { recordPlacePick } from '../../api/placeShadow';
import { usePlaceSearch, type PlaceSearchOptions } from './usePlaceSearch';
vi.mock('../../api/placeShadow', () => ({ recordPlacePick: vi.fn() }));
type SearchResult = Awaited<ReturnType<typeof mapsApi.search>>;
type AutocompleteResult = Awaited<ReturnType<typeof mapsApi.autocomplete>>;
type DetailsResult = Awaited<ReturnType<typeof mapsApi.details>>;
type ResolveResult = Awaited<ReturnType<typeof mapsApi.resolveUrl>>;
type NearbyResult = Awaited<ReturnType<typeof mapsApi.nearby>>;
const t = (key: string) => key;
const BOX = { low: { lat: 48, lng: 2 }, high: { lat: 49, lng: 3 } };
const LOUVRE = { name: 'Louvre', address: 'Rue de Rivoli', lat: 48.86, lng: 2.33, osm_id: 'W1' };
const ORSAY = { name: 'Orsay', address: 'Quai', lat: 48.85, lng: 2.32, google_place_id: 'g-orsay' };
const SUGGESTION = { placeId: 'sug-1', mainText: 'Louvre', secondaryText: 'Paris' };
const makeToast = () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() });
let toast: ReturnType<typeof makeToast>;
function setup(variant: 'dialog' | 'sheet', over: Partial<PlaceSearchOptions> = {}) {
const onPlace = vi.fn();
const onSuggestionName = vi.fn();
const onMapLink = vi.fn();
const onCoordinates = vi.fn();
const onResolvingChange = vi.fn();
const hook = renderHook(
(p: { open?: boolean }) =>
usePlaceSearch({
variant,
locationBias: BOX,
t,
toast,
open: p.open,
onPlace,
onSuggestionName,
onMapLink,
onCoordinates,
onResolvingChange,
...over,
}),
{ initialProps: { open: variant === 'dialog' ? true : undefined } }
);
return { ...hook, onPlace, onSuggestionName, onMapLink, onCoordinates, onResolvingChange };
}
type Hook = { current: ReturnType<typeof usePlaceSearch> };
function mockSearch(places: Record<string, unknown>[] = [LOUVRE, ORSAY], source = 'osm') {
return vi.spyOn(mapsApi, 'search').mockResolvedValue({ places, source } as unknown as SearchResult);
}
function mockAutocomplete(suggestions: Record<string, unknown>[] = [SUGGESTION], source = 'osm') {
return vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({ suggestions, source } as unknown as AutocompleteResult);
}
async function searchFor(result: Hook, query: string, provider?: 'google') {
act(() => result.current.setQuery(query));
await act(async () => {
await result.current.runSearch(provider);
});
}
beforeEach(() => {
resetAllStores();
toast = makeToast();
vi.mocked(recordPlacePick).mockClear();
// A typed query fires a debounced request; tests that do not look at it get an empty list.
mockAutocomplete([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('usePlaceSearch: typed-ahead suggestions', () => {
it('FE-PLANNER-PLSEARCH-001: asks after the debounce with the language, the bias box and a session token', async () => {
const spy = mockAutocomplete();
const { result } = setup('dialog');
act(() => result.current.setQuery(' Lo '));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
expect(spy).toHaveBeenCalledWith('Lo', 'en', BOX, expect.any(AbortSignal), expect.any(String));
await waitFor(() => expect(result.current.suggestions).toEqual([SUGGESTION]));
expect(result.current.acSource).toBe('osm');
expect(result.current.highlight).toBe(-1);
});
it('FE-PLANNER-PLSEARCH-002: stays quiet below two characters and for a map link', async () => {
const spy = mockAutocomplete();
const { result } = setup('dialog');
act(() => result.current.setQuery('L'));
act(() => result.current.setQuery('https://maps.app.goo.gl/abc'));
await new Promise((r) => setTimeout(r, 400));
expect(spy).not.toHaveBeenCalled();
expect(result.current.suggestions).toEqual([]);
});
it('FE-PLANNER-PLSEARCH-003: a coordinate pair gets suggestions on the desktop but not on the phone', async () => {
const spy = mockAutocomplete();
const desktop = setup('dialog');
act(() => desktop.result.current.setQuery('48.85, 2.35'));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
desktop.unmount();
spy.mockClear();
const phone = setup('sheet');
act(() => phone.result.current.setQuery('48.85, 2.35'));
await new Promise((r) => setTimeout(r, 400));
expect(spy).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLSEARCH-004: a failed list is logged on the desktop only and empties the list on both', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(mapsApi, 'autocomplete').mockRejectedValue(new Error('boom'));
const desktop = setup('dialog');
await act(async () => {
await desktop.result.current.fetchSuggestions('Louvre');
});
expect(error).toHaveBeenCalledWith('Autocomplete failed:', expect.any(Error));
expect(desktop.result.current.suggestions).toEqual([]);
desktop.unmount();
error.mockClear();
const phone = setup('sheet');
await act(async () => {
await phone.result.current.fetchSuggestions('Louvre');
});
expect(error).not.toHaveBeenCalled();
expect(phone.result.current.suggestions).toEqual([]);
});
it('FE-PLANNER-PLSEARCH-005: an aborted request leaves the desktop list alone but empties the phone list', async () => {
const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
const spy = mockAutocomplete();
const desktop = setup('dialog');
await act(async () => {
await desktop.result.current.fetchSuggestions('Louvre');
});
spy.mockRejectedValue(aborted);
await act(async () => {
await desktop.result.current.fetchSuggestions('Louvre M');
});
expect(desktop.result.current.suggestions).toEqual([SUGGESTION]);
desktop.unmount();
spy.mockResolvedValue({ suggestions: [SUGGESTION], source: 'osm' } as unknown as AutocompleteResult);
const phone = setup('sheet');
await act(async () => {
await phone.result.current.fetchSuggestions('Louvre');
});
spy.mockRejectedValue(aborted);
await act(async () => {
await phone.result.current.fetchSuggestions('Louvre M');
});
expect(phone.result.current.suggestions).toEqual([]);
});
});
describe('usePlaceSearch: full search', () => {
it('FE-PLANNER-PLSEARCH-006: searches the trimmed query biased to the centre of the box', async () => {
const spy = mockSearch();
const { result, onResolvingChange } = setup('dialog');
await searchFor(result, ' Louvre ');
expect(spy).toHaveBeenCalledWith('Louvre', 'en', expect.objectContaining({ lat: 48.5, lng: 2.5 }), undefined);
expect(result.current.results).toEqual([LOUVRE, ORSAY]);
expect(result.current.searchSource).toBe('osm');
expect(result.current.nearbyList).toBe(false);
expect(result.current.emptySearch).toBeNull();
expect(result.current.searching).toBe(false);
expect(onResolvingChange.mock.calls).toEqual([[true], [false]]);
});
it('FE-PLANNER-PLSEARCH-007: an empty answer names the query on the desktop only', async () => {
mockSearch([]);
const desktop = setup('dialog');
await searchFor(desktop.result, 'Nowhere');
expect(desktop.result.current.emptySearch).toBe('Nowhere');
desktop.unmount();
const phone = setup('sheet');
await searchFor(phone.result, 'Nowhere');
expect(phone.result.current.emptySearch).toBeNull();
});
it('FE-PLANNER-PLSEARCH-008: the Google retry sends the query the list came from', async () => {
const spy = mockSearch();
const { result } = setup('dialog');
await searchFor(result, 'Louvre');
act(() => result.current.setQuery('something else'));
await act(async () => {
await result.current.runSearch('google');
});
expect(spy).toHaveBeenLastCalledWith('Louvre', 'en', expect.anything(), 'google');
});
it('FE-PLANNER-PLSEARCH-009: the phone takes a typed coordinate pair as the position, without a request', async () => {
const spy = mockSearch();
const { result, onCoordinates } = setup('sheet');
await searchFor(result, '48.85, 2.35');
expect(spy).not.toHaveBeenCalled();
expect(onCoordinates).toHaveBeenCalledWith('48.85', '2.35');
expect(result.current.query).toBe('');
});
it('FE-PLANNER-PLSEARCH-010: the desktop searches a coordinate pair like any other text', async () => {
const spy = mockSearch();
const { result, onCoordinates } = setup('dialog');
await searchFor(result, '48.85, 2.35');
expect(spy).toHaveBeenCalledWith('48.85, 2.35', 'en', expect.anything(), undefined);
expect(onCoordinates).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLSEARCH-011: a map link resolves into a position; only the desktop clears its list', async () => {
const resolved = { name: 'Louvre', address: 'Paris', lat: 48.86, lng: 2.33, google_ftid: 'ft' };
vi.spyOn(mapsApi, 'resolveUrl').mockResolvedValue(resolved as unknown as ResolveResult);
mockSearch();
const link = 'https://maps.app.goo.gl/abc';
const desktop = setup('dialog');
await searchFor(desktop.result, 'Louvre');
await searchFor(desktop.result, link);
expect(desktop.onMapLink).toHaveBeenCalledWith(resolved);
expect(toast.success).toHaveBeenCalledWith('places.urlResolved');
expect(desktop.result.current.results).toEqual([]);
expect(desktop.result.current.query).toBe('');
desktop.unmount();
const phone = setup('sheet');
await searchFor(phone.result, 'Louvre');
await searchFor(phone.result, link);
expect(phone.onMapLink).toHaveBeenCalledWith(resolved);
expect(phone.result.current.results).toEqual([LOUVRE, ORSAY]);
expect(phone.result.current.query).toBe('');
});
it('FE-PLANNER-PLSEARCH-012: a failed search toasts the server message or the fallback', async () => {
vi.spyOn(mapsApi, 'search').mockRejectedValue(new Error('down'));
const { result } = setup('sheet');
await searchFor(result, 'Louvre');
expect(toast.error).toHaveBeenCalledTimes(1);
expect(result.current.searching).toBe(false);
});
it('FE-PLANNER-PLSEARCH-013: the phone ends the billing session after a full search, the desktop keeps it', async () => {
const auto = mockAutocomplete();
mockSearch();
const tokensAround = async (variant: 'dialog' | 'sheet') => {
auto.mockClear();
const { result, unmount } = setup(variant);
await act(async () => {
await result.current.fetchSuggestions('Louvre');
});
await searchFor(result, 'Louvre');
await act(async () => {
await result.current.fetchSuggestions('Louvre M');
});
unmount();
return auto.mock.calls.map((call) => call[4]);
};
const [desktopFirst, desktopSecond] = await tokensAround('dialog');
expect(desktopSecond).toBe(desktopFirst);
const [phoneFirst, phoneSecond] = await tokensAround('sheet');
expect(phoneSecond).not.toBe(phoneFirst);
});
});
describe('usePlaceSearch: picking', () => {
it('FE-PLANNER-PLSEARCH-014: a search row goes to the form with its mark and is logged with its rank', async () => {
mockSearch();
const { result, onPlace } = setup('dialog');
await searchFor(result, 'Louvre');
act(() => result.current.pickPlace(ORSAY, { mode: 'search', rank: 1, count: 2 }));
expect(onPlace.mock.calls[0][0]).toBe(ORSAY);
expect(recordPlacePick).toHaveBeenCalledWith(
expect.objectContaining({
query: 'Louvre',
lang: 'en',
biasLat: 48.5,
biasLng: 2.5,
source: 'search:osm',
liveRank: 1,
liveCount: 2,
pickedName: 'Orsay',
pickedLat: 48.85,
pickedLng: 2.32,
pickedPlaceId: 'g-orsay',
})
);
expect(result.current.results).toEqual([]);
expect(result.current.query).toBe('');
});
it('FE-PLANNER-PLSEARCH-015: a place from elsewhere carries no mark and is not logged', () => {
const { result, onPlace } = setup('dialog');
act(() => result.current.pickPlace(LOUVRE));
expect(onPlace).toHaveBeenCalledWith(LOUVRE, null);
expect(recordPlacePick).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLSEARCH-016: the phone clears the typed-ahead list on a pick, the desktop leaves it', async () => {
mockAutocomplete();
const desktop = setup('dialog');
await act(async () => {
await desktop.result.current.fetchSuggestions('Louvre');
});
act(() => desktop.result.current.pickPlace(LOUVRE));
expect(desktop.result.current.suggestions).toEqual([SUGGESTION]);
desktop.unmount();
const phone = setup('sheet');
await act(async () => {
await phone.result.current.fetchSuggestions('Louvre');
});
act(() => phone.result.current.pickPlace(LOUVRE));
expect(phone.result.current.suggestions).toEqual([]);
});
it('FE-PLANNER-PLSEARCH-017: a suggestion fills the name first, then its looked up place', async () => {
mockAutocomplete();
const details = vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: LOUVRE } as unknown as DetailsResult);
const { result, onSuggestionName, onPlace } = setup('sheet');
await act(async () => {
await result.current.fetchSuggestions('Louvre');
});
await act(async () => {
await result.current.selectSuggestion(SUGGESTION);
});
expect(onSuggestionName).toHaveBeenCalledWith('Louvre');
expect(details).toHaveBeenCalledWith('sug-1', 'en', expect.any(String));
expect(onPlace.mock.calls[0][0]).toBe(LOUVRE);
expect(recordPlacePick).toHaveBeenCalledWith(expect.objectContaining({ source: 'autocomplete:osm', liveRank: 0 }));
expect(result.current.searching).toBe(false);
});
it('FE-PLANNER-PLSEARCH-018: an OpenStreetMap row without details uses its own position, never a text search', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(mapsApi, 'details').mockRejectedValue(new Error('no details'));
const search = mockSearch();
const row = {
placeId: 'N5',
mainText: 'Tokyo Station',
secondaryText: '東京駅',
source: 'openstreetmap',
lat: 35.68,
lng: 139.77,
};
const { result, onPlace } = setup('dialog');
await act(async () => {
await result.current.selectSuggestion(row);
});
expect(search).not.toHaveBeenCalled();
expect(onPlace.mock.calls[0][0]).toEqual({
name: 'Tokyo Station',
address: '',
lat: 35.68,
lng: 139.77,
osm_id: 'N5',
source: 'openstreetmap',
});
});
it('FE-PLANNER-PLSEARCH-019: a suggestion nothing answers for puts the query back and says so', async () => {
vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: null } as unknown as DetailsResult);
const search = mockSearch([]);
const { result, onPlace } = setup('sheet');
act(() => result.current.setQuery('Louvre'));
await act(async () => {
await result.current.selectSuggestion(SUGGESTION);
});
expect(search).toHaveBeenCalledWith('Louvre, Paris', 'en', expect.anything());
expect(onPlace).not.toHaveBeenCalled();
expect(result.current.query).toBe('Louvre');
expect(toast.error).toHaveBeenCalledWith('places.mapsSearchError');
});
});
describe('usePlaceSearch: the desktop dialog', () => {
it('FE-PLANNER-PLSEARCH-020: closing clears the search, and an answer arriving after it is dropped', async () => {
let answer: (value: SearchResult) => void = () => {};
vi.spyOn(mapsApi, 'search').mockReturnValue(new Promise<SearchResult>((resolve) => (answer = resolve)));
const { result, rerender } = setup('dialog');
act(() => result.current.setQuery('Louvre'));
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.runSearch();
});
expect(result.current.searching).toBe(true);
rerender({ open: false });
expect(result.current.query).toBe('');
expect(result.current.searching).toBe(false);
await act(async () => {
answer({ places: [LOUVRE], source: 'osm' } as unknown as SearchResult);
await pending;
});
expect(result.current.results).toEqual([]);
});
it('FE-PLANNER-PLSEARCH-021: places near the pin replace the list and say when there are none', async () => {
const nearby = vi
.spyOn(mapsApi, 'nearby')
.mockResolvedValue({ places: [], source: 'osm' } as unknown as NearbyResult);
const { result } = setup('dialog');
await act(async () => {
await result.current.searchNearby(null);
});
expect(nearby).not.toHaveBeenCalled();
await act(async () => {
await result.current.searchNearby({ lat: 48.8, lng: 2.3 });
});
expect(nearby).toHaveBeenCalledWith(48.8, 2.3, 'en');
expect(result.current.nearbyList).toBe(true);
expect(toast.info).toHaveBeenCalledWith('places.nearbyNone');
});
it('FE-PLANNER-PLSEARCH-022: arrows walk the list, Enter picks the highlighted row, Escape closes the list', async () => {
mockAutocomplete([SUGGESTION, { placeId: 'sug-2', mainText: 'Orsay', secondaryText: '' }]);
vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: ORSAY } as unknown as DetailsResult);
const { result, onPlace } = setup('dialog');
const key = (k: string) => ({ key: k, preventDefault: vi.fn() }) as unknown as React.KeyboardEvent;
await act(async () => {
await result.current.fetchSuggestions('Lou');
});
act(() => result.current.handleKeyDown(key('ArrowDown')));
act(() => result.current.handleKeyDown(key('ArrowDown')));
expect(result.current.highlight).toBe(1);
act(() => result.current.handleKeyDown(key('ArrowUp')));
expect(result.current.highlight).toBe(0);
act(() => result.current.handleKeyDown(key('Escape')));
expect(result.current.suggestions).toEqual([]);
expect(result.current.highlight).toBe(-1);
await act(async () => {
await result.current.fetchSuggestions('Lou');
});
act(() => result.current.handleKeyDown(key('ArrowUp')));
await act(async () => {
result.current.handleKeyDown(key('Enter'));
});
await waitFor(() => expect(onPlace).toHaveBeenCalledTimes(1));
expect(onPlace.mock.calls[0][0]).toBe(ORSAY);
});
});
@@ -0,0 +1,469 @@
import type { MapsResolveUrlResult } from '@trek/shared';
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { mapsApi } from '../../api/client';
import { recordPlacePick } from '../../api/placeShadow';
import { pointFromBox, type LocationBiasBox } from '../../hooks/useLocationBias';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions';
import type { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { getApiErrorMessage } from '../../utils/apiError';
import { corePickRank, selectGoogleHoldsSlot } from '../../utils/placeSource';
import { PlacesSession } from '../../utils/placesSession';
import type { useToast } from '../shared/Toast';
import { isMapUrl, parseCoordinatePair } from './PlaceFormModal.helpers';
/** A place as a provider hands it back: loosely typed, kept open by design. */
export type MapsPlace = Record<string, unknown>;
/** A row of a full or nearby search, read the way both lists render it. */
export type MapsSearchRow = MapsPlace & { name?: string; address?: string; distance_m?: number };
/**
* One row of the typed-ahead list, as the server sends it.
*
* `source`, `lat` and `lng` are optional because not every index fills them:
* Google answers with neither, and the mark falls back to the name the whole
* list carries. `place` is only on a plugin's row, which brings its whole place
* along because no details lookup knows a plugin id (#2221).
*/
export interface PlaceSuggestion {
placeId: string;
mainText: string;
secondaryText: string;
source?: string;
lat?: number;
lng?: number;
place?: MapsPlace;
}
/** Where a pick sat in a ranked list, for the shadow log. */
export interface PlaceSearchPick {
mode: 'search' | 'autocomplete';
rank: number;
count: number;
}
export interface PlaceSearchOptions {
/**
* The two shells search a little differently, and each keeps its own way.
*
* `dialog` (desktop): the search button and the keys clear the typed-ahead list
* themselves, failures are logged to the console and an aborted keystroke leaves its
* list alone, a query cut below two characters ends the billing session, a full search
* leaves the session open, and a resolved map link clears the result list as well.
*
* `sheet` (phone): a typed "lat, lng" pair is a position rather than a query, so it
* gets no suggestions and the search button takes it as the coordinates; every full
* search ends the billing session, and a pick clears the typed-ahead list too.
*/
variant: 'dialog' | 'sheet';
/** Where the trip is happening: autocomplete is biased to the box, the full search to its centre. */
locationBias: LocationBiasBox | undefined;
t: ReturnType<typeof useTranslation>['t'];
toast: ReturnType<typeof useToast>;
/**
* The desktop dialog stays mounted while it is closed, so each closing ends what the
* search held: the query, its lists, a request still on its way. The phone's sheet
* unmounts instead and passes nothing.
*/
open?: boolean;
/** A place picked from a list or found for a suggestion; `label` names the index it came from. */
onPlace: (place: MapsPlace, label: string | null) => void;
/** A suggestion was clicked: its name goes into the form while its place is looked up. */
onSuggestionName: (name: string) => void;
/** A pasted Google Maps or Amap link the server turned into a position. */
onMapLink: (resolved: MapsResolveUrlResult) => void;
/** A typed coordinate pair, taken as the position itself (the phone). */
onCoordinates?: (lat: string, lng: string) => void;
/** True while a search or a pick is being looked up. */
onResolvingChange?: (resolving: boolean) => void;
}
/**
* The maps search of the place form: the one logic path behind the desktop dialog's
* search block and the phone sheet's search row, which render their own markup over it.
* Typed-ahead suggestions (debounced, the stale request aborted, one Google billing
* session per search), a full search biased to the trip, a pasted map link resolved into
* a position, places near the pin, and picking a row, which hands the place to the form
* and records the pick for the shadow log.
*/
export function usePlaceSearch({
variant,
locationBias,
t,
toast,
open,
onPlace,
onSuggestionName,
onMapLink,
onCoordinates,
onResolvingChange,
}: PlaceSearchOptions) {
const sheet = variant === 'sheet';
// Place names in the language the user picked for them, the app's otherwise (#1799).
const language = usePlaceLanguage();
const googleAnswers = useAuthStore(selectGoogleHoldsSlot);
const { autocomplete, sourceLabel } = usePlaceSuggestions();
// The same hint autocomplete gets, as a point instead of a box.
const biasPoint = pointFromBox(locationBias);
const [query, setQuery] = useState('');
const [results, setResults] = useState<MapsSearchRow[]>([]);
/** What answered the last full search. Only a fallback: a merged list carries the source per place. */
const [searchSource, setSearchSource] = useState('');
// The list on screen answers "what is near the pin" rather than a typed query (#976).
const [nearbyList, setNearbyList] = useState(false);
// The query the last full search found nothing for (#2472): the cue to add the
// place by hand instead of a silent empty list.
const [emptySearch, setEmptySearch] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [suggestions, setSuggestions] = useState<PlaceSuggestion[]>([]);
// Which index answered the last keystroke, for the rows that do not say so
// themselves. Google and the OpenStreetMap fallback each answer from one
// place; the index path answers from two at once and marks every row.
const [acSource, setAcSource] = useState('');
const [highlight, setHighlight] = useState(-1);
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
* state because nothing renders from it and a pick must read the value that
* belonged to the list, not a value a re-render replaced.
*/
const searchMetaRef = useRef<{ query: string; source: string } | null>(null);
const acMetaRef = useRef<{ query: string; source: string } | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Counts the closings of the dialog, so an answer still on its way when it
// closed can tell that the opening it was asked for is over. A counter rather
// than an abort, because the full search takes no signal.
const epochRef = useRef(0);
// Ties one search's keystrokes and its details lookup into a single Google
// billing session (see utils/placesSession).
const sessionRef = useRef(new PlacesSession());
const setBusy = (busy: boolean) => {
setSearching(busy);
onResolvingChange?.(busy);
};
// Everything the search block holds would otherwise greet the next opening:
// the last query, its list, the Google line offering that list's query again,
// suggestions still on their way. A pick from that list writes over the place
// being edited, whichever place that is by then.
useEffect(() => {
if (open !== false) return;
epochRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current);
abortRef.current?.abort();
sessionRef.current.end();
searchMetaRef.current = null;
acMetaRef.current = null;
setQuery('');
setResults([]);
setSearchSource('');
setSuggestions([]);
setAcSource('');
setHighlight(-1);
setSearching(false);
}, [open]);
// Autocomplete fetch: aborts any in-flight request before starting a new one.
const fetchSuggestions = useCallback(
async (input: string) => {
if (input.length < 2 || isMapUrl(input)) {
setSuggestions([]);
setHighlight(-1);
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const result = await autocomplete(
input,
language,
locationBias,
controller.signal,
sessionRef.current.current()
);
acMetaRef.current = { query: input, source: result.source || 'unknown' };
setSuggestions(result.suggestions || []);
setAcSource(result.source || '');
setHighlight(-1);
} catch (err: unknown) {
if (!sheet && err instanceof Error && err.name === 'AbortError') return;
// Superseded request: axios rejects an aborted call with CanceledError.
if (err instanceof Error && err.name === 'CanceledError') return;
if (!sheet) console.error('Autocomplete failed:', err);
setSuggestions([]);
}
},
[autocomplete, language, locationBias, sheet]
);
// Debounced autocomplete, watching only the query. Map links go to the search
// button, and so do coordinate pairs on the phone.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = query.trim();
if (trimmed.length < 2 || isMapUrl(trimmed) || (sheet && parseCoordinatePair(trimmed) !== null)) {
// A list still on its way belongs to a query that is gone.
abortRef.current?.abort();
setSuggestions([]);
if (!sheet) {
setHighlight(-1);
sessionRef.current.end();
}
return;
}
debounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query, fetchSuggestions, sheet]);
/** Hands a place to the form, logs where it was picked, and clears what led to it. */
const take = (place: MapsPlace, pick: PlaceSearchPick | undefined, label: string | null) => {
onPlace(place, label);
// `pick` is present only when the place came out of a ranked list. The
// collection picker, a coordinate pair and a resolved link reach the form
// with a place that was never ranked against a query, and a made-up rank
// would be worse than no row at all.
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current;
const lat = Number(place.lat);
const lng = Number(place.lng);
if (meta && Number.isFinite(lat) && Number.isFinite(lng)) {
recordPlacePick({
query: meta.query,
lang: language,
// The bias the search actually ran under is a box around the trip's
// existing places; the corpus stores its centre, which is what an
// evaluation needs to bias its own index the same way.
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: sheet ? String(place.name ?? '') : (place.name as string) || '',
pickedLat: lat,
pickedLng: lng,
pickedPlaceId:
(place.google_place_id as string) || (place.amap_poi_id as string) || (place.osm_id as string) || null,
});
}
}
setResults([]);
if (sheet) setSuggestions([]);
setQuery('');
};
/** A row of a list, or a place from elsewhere (the saved-place picker) when `pick` is absent. */
const pickPlace = (place: MapsPlace, pick?: PlaceSearchPick) =>
take(place, pick, sourceLabel(place, pick?.mode === 'search' ? searchSource : ''));
const runSearch = async (provider?: 'google') => {
// The retry sends the query the list came from, not the field: the list
// stays on screen while the field is edited or cleared, and the line under
// it promises the same query.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim();
if (!trimmed) return;
if (sheet) {
setSuggestions([]);
// "lat, lng" typed or pasted: straight to coordinates, no lookup needed.
const pair = parseCoordinatePair(trimmed);
if (pair) {
onCoordinates?.(pair[0], pair[1]);
setQuery('');
return;
}
}
const epoch = epochRef.current;
setBusy(true);
try {
// A pasted Google Maps or Amap link resolves server-side into a place
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed);
if (epoch !== epochRef.current) return;
if (resolved.lat && resolved.lng) {
onMapLink(resolved);
if (!sheet) setResults([]);
setQuery('');
toast.success(t('places.urlResolved'));
return;
}
}
const result = await mapsApi.search(trimmed, language, biasPoint, provider);
if (epoch !== epochRef.current) return;
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' };
const places = (result.places || []) as MapsSearchRow[];
if (!sheet) setNearbyList(false);
setResults(places);
if (!sheet) setEmptySearch(places.length === 0 ? trimmed : null);
setSearchSource(result.source || '');
} catch (err: unknown) {
if (epoch !== epochRef.current) return;
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')));
} finally {
if (epoch === epochRef.current) setBusy(false);
if (sheet) sessionRef.current.end();
}
};
// What is around the pin the form holds (#976): the list a search would show,
// nearest first. Not logged as a search pick, because no query was typed.
const searchNearby = async (pin: { lat: number; lng: number } | null) => {
if (!pin) return;
const epoch = epochRef.current;
setBusy(true);
setSuggestions([]);
try {
const result = await mapsApi.nearby(pin.lat, pin.lng, language);
if (epoch !== epochRef.current) return;
searchMetaRef.current = null;
setNearbyList(true);
setResults((result.places || []) as MapsSearchRow[]);
setSearchSource(result.source || '');
if (!result.places?.length) toast.info(t('places.nearbyNone'));
} catch (err: unknown) {
if (epoch !== epochRef.current) return;
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')));
} finally {
if (epoch === epochRef.current) setBusy(false);
}
};
const selectSuggestion = async (suggestion: PlaceSuggestion) => {
// Read before the list is cleared: this is the rank the user saw.
const acPick = corePickRank(suggestions, suggestion);
abortRef.current?.abort();
setSuggestions([]);
setHighlight(-1);
const previousQuery = query;
const epoch = epochRef.current;
setQuery('');
onSuggestionName(suggestion.mainText);
setBusy(true);
try {
// The details lookup is a fragile second hop: it can fail when the
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
// or on any upstream error. Treat a missing/coordinate-less place as a
// miss and fall back to the reliable text-search path the search button
// uses (its results already carry coordinates), so dropdown items stay
// clickable instead of dead-ending on "Place search failed". (#1192)
let place: MapsPlace | null = suggestion.place ?? null;
if (!place) {
try {
// Spends the session the suggestions opened, so Google bills the search
// once rather than per keystroke.
const result = await mapsApi.details(suggestion.placeId, language, sessionRef.current.peek());
if (result.place && result.place.lat != null && result.place.lng != null) place = result.place;
} catch (err) {
if (!sheet) console.error('Failed to fetch place details:', err);
}
}
// Closed while the details were on their way: the pick belongs to an
// opening that is over, and the fallback search below is not worth a
// request nobody will see.
if (epoch !== epochRef.current) return;
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's rows carry no address; their second line is the name
// written on the building. Searching for "Tokio Hauptbahnhof, 東京駅"
// is not a question anybody asked, and its first answer would be
// whatever the index made of it: a different place, chosen silently.
// The suggestion already knows where it is, so use that.
place = {
name: suggestion.mainText,
address: '',
lat: suggestion.lat,
lng: suggestion.lng,
osm_id: suggestion.placeId,
source: 'openstreetmap',
};
}
if (!place) {
const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ');
const search = await mapsApi.search(fullQuery, language, biasPoint);
if (epoch !== epochRef.current) return;
place = search.places?.[0] ?? null;
}
if (place) {
// Named after the row that was clicked, whatever the lookup behind it answered from.
take(place, acPick && { mode: 'autocomplete', ...acPick }, sourceLabel(suggestion, acSource));
} else {
setQuery(previousQuery);
toast.error(t('places.mapsSearchError'));
}
} catch (err) {
if (epoch !== epochRef.current) return;
if (!sheet) console.error('Place suggestion lookup failed:', err);
setQuery(previousQuery);
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')));
} finally {
// Closing already ended this session; ending it again here would cut off
// the one a new opening may have started meanwhile.
if (epoch === epochRef.current) {
setBusy(false);
sessionRef.current.end();
}
}
};
/** The desktop field's keys: arrows walk the typed-ahead list, Enter picks or searches, Escape closes the list. */
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlight((prev) => (prev + 1) % suggestions.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((prev) => (prev <= 0 ? suggestions.length - 1 : prev - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlight >= 0) {
void selectSuggestion(suggestions[highlight]);
} else {
setSuggestions([]);
void runSearch();
}
} else if (e.key === 'Escape') {
// Spent on the list: the dialog leaves an Escape that was already
// handled alone, so only the next one closes it.
e.preventDefault();
setSuggestions([]);
setHighlight(-1);
}
} else if (e.key === 'Enter') {
e.preventDefault();
void runSearch();
}
};
return {
language,
googleAnswers,
sourceLabel,
query,
setQuery,
results,
searchSource,
nearbyList,
emptySearch,
setEmptySearch,
searching,
suggestions,
setSuggestions,
acSource,
highlight,
setHighlight,
fetchSuggestions,
runSearch,
searchNearby,
pickPlace,
selectSuggestion,
handleKeyDown,
};
}
@@ -3,10 +3,15 @@ import { MapPin } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import {
DEFAULT_FORM,
endsBeforeStart,
mergeResult,
parseCoordinatePair,
placeEditForm,
placeFormPayload,
prefillForm,
type PlaceFormData,
type ResultField,
} from '../../../../components/Planner/PlaceFormModal.helpers'
import { usePlaceForm } from '../../../../components/Planner/usePlaceForm'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { useAddonStore } from '../../../../store/addonStore'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
@@ -15,7 +20,7 @@ import PlCategoryPicker from './PlCategoryPicker'
import PlTimeFields from './PlTimeFields'
import PlFileAttach from './PlFileAttach'
import MLinkedCosts from './MLinkedCosts'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from '../../../../components/Planner/PlaceDetailsColumn'
import PlaceDetailsColumn from '../../../../components/Planner/PlaceDetailsColumn'
import { useTranslation } from '../../../../i18n'
import { useAuthStore } from '../../../../store/authStore'
import { useSettingsStore } from '../../../../store/settingsStore'
@@ -29,30 +34,6 @@ export interface MPlaceEditSheetProps {
onOpenExpense: (req: BookingExpenseRequest) => void
}
// #1152: same duplicate heuristic as the desktop form — shared Google Place ID,
// case-insensitive name match, or near-identical coordinates (~11 m).
const DUP_COORD_TOLERANCE = 0.0001
function findDuplicateName(
form: PlaceFormData,
places: { name?: string | null; lat?: number | string | null; lng?: number | string | null; google_place_id?: string | null }[],
): string | null {
const name = form.name.trim().toLowerCase()
const gid = (form.google_place_id || '').trim()
const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null
for (const p of places || []) {
if (gid && p.google_place_id && p.google_place_id === gid) return p.name || form.name
if (name && p.name && p.name.trim().toLowerCase() === name) return p.name
if (
lat != null && lng != null && p.lat != null && p.lng != null &&
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
) return p.name || form.name
}
return null
}
/**
* The visit the editor was opened on, looked up in the planner's STORED list.
*
@@ -86,13 +67,14 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
handleSavePlace, setDeletePlaceId, confirmDeletePlace,
} = planner
const [form, setForm] = useState<PlaceFormData>(DEFAULT_FORM)
// Which fields the last picked search result wrote. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set())
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [isSaving, setIsSaving] = useState(false)
// The fields, which of them the last picked search result wrote (see mergeResult), the
// pending files and the details block follow the same rules as the desktop dialog.
const {
form, setForm, autoFilledRef, pendingFiles, addFiles, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange, pickImage, adoptDescription,
warnIfDuplicate,
} = usePlaceForm({ t, toast, canUploadFiles })
const [resolvingPick, setResolvingPick] = useState(false)
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
// Set right before submit: the place has to exist before an expense can point
// at it, exactly like MReservationSheet does it.
@@ -104,10 +86,6 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
const [sheetPlace, setSheetPlace] = useState<Place | null>(null)
const [sheetAssignmentId, setSheetAssignmentId] = useState<number | null>(null)
// The details block under the search field follows the same selection rules
// as the desktop dialog's left column: the picked search result, else the
// place being edited, else whatever a map POI prefilled.
const [detailsSelection, setDetailsSelection] = useState<PlaceDetailsSelection | null>(null)
const placesEnrichEnabled = useAuthStore(s => s.placesEnrichEnabled)
const { language, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -130,76 +108,18 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
}, [sheetPlace, assignments, ctxAssignment])
// Prefill on open — same source order as the desktop form: editing place
// (times off the in-context assignment), map/POI prefill coords, blank.
// (times off the in-context assignment), map/POI prefill coords, blank. The
// details block follows the same order, so it never keeps showing the previous
// sheet's place.
useEffect(() => {
if (!showPlaceForm) return
setSheetPlace(editingPlace)
setSheetAssignmentId(editingAssignmentId)
if (editingPlace) {
const assignment = findVisit(storedAssignments, editingAssignmentId)
const timeSource = assignment?.place ?? editingPlace
setForm({
name: editingPlace.name || '',
description: editingPlace.description || '',
address: editingPlace.address || '',
lat: editingPlace.lat != null ? String(editingPlace.lat) : '',
lng: editingPlace.lng != null ? String(editingPlace.lng) : '',
category_id: editingPlace.category_id != null ? String(editingPlace.category_id) : '',
place_time: timeSource.place_time || '',
end_time: timeSource.end_time || '',
notes: editingPlace.notes || '',
transport_mode: editingPlace.transport_mode || 'walking',
website: editingPlace.website || '',
// The day-specific note rides only with an assignment in context (#2163);
// otherwise the key stays absent so submit never sends a notes write.
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
})
} else if (prefillCoords) {
setForm({
...DEFAULT_FORM,
lat: String(prefillCoords.lat),
lng: String(prefillCoords.lng),
name: prefillCoords.name || '',
address: prefillCoords.address || '',
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
})
} else {
setForm(DEFAULT_FORM)
}
// Same source order as the desktop dialog: an existing place's provider id
// and coordinates, a map POI's prefill, or nothing. Without this the block
// would keep showing the previous sheet's place.
if (editingPlace && editingPlace.lat != null && editingPlace.lng != null) {
setDetailsSelection({
placeId: editingPlace.google_place_id || editingPlace.amap_poi_id || editingPlace.osm_id || undefined,
lat: Number(editingPlace.lat),
lng: Number(editingPlace.lng),
name: editingPlace.name || '',
})
} else if (prefillCoords) {
setDetailsSelection({
placeId: prefillCoords.osm_id || undefined,
lat: prefillCoords.lat,
lng: prefillCoords.lng,
name: prefillCoords.name || '',
})
} else {
setDetailsSelection(null)
}
// A fresh sheet owns nothing yet; one opened on a map POI owns whatever
// that POI filled in. An existing place being edited owns nothing either —
// everything on that form came out of the database.
autoFilledRef.current = new Set(
!editingPlace && prefillCoords
? (['name', 'address', 'lat', 'lng', 'website', 'phone', 'osm_id'] as ResultField[]).filter(
(field) => !!prefillCoords[field as keyof typeof prefillCoords],
)
: [],
)
setPendingFiles([])
setDuplicateWarning(null)
let next: PlaceFormData
if (editingPlace) next = placeEditForm(editingPlace, findVisit(storedAssignments, editingAssignmentId))
else if (prefillCoords) next = prefillForm(prefillCoords)
else next = DEFAULT_FORM
openForm(next, editingPlace, prefillCoords)
setDeleteArmed(false)
// storedAssignments is a fresh map each load, so it is read at open time only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -210,12 +130,6 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
// while it still fits inside one region.
const { box: locationBias } = useLocationBias()
const handleChange = (field: keyof PlaceFormData, value: string) => {
// Typed by hand, so the next pick must leave it alone.
autoFilledRef.current.delete(field as ResultField)
setForm(prev => ({ ...prev, [field]: value }))
}
// Same fix as the desktop dialog, same helper. `?? prev.X` cannot tell a
// value the user typed from one the previous pick wrote, so searching an
// airport and then a station left the airport's website in the field.
@@ -248,37 +162,16 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
}
const handleCoordPaste = (e: ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
// Same grammar as before, spelled without the overlapping quantifiers pasted text
// could make backtrack: the separator is either a comma/semicolon with optional
// padding or pure whitespace, and a decimal is digits with an optional ".digits".
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
if (match) {
const pair = parseCoordinatePair(e.clipboardData.getData('text'))
if (pair) {
e.preventDefault()
setForm(prev => ({ ...prev, lat: match[1], lng: match[2] }))
}
}
// Clipboard images/PDFs from any focused field become pending attachments.
const handlePaste = (e: ClipboardEvent) => {
if (!canUploadFiles) return
for (const item of Array.from(e.clipboardData?.items || [])) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
if (file) setPendingFiles(prev => [...prev, file])
return
}
setForm(prev => ({ ...prev, lat: pair[0], lng: pair[1] }))
}
}
// End before start blocks the save — tied to the values, not to which entry
// point opened the sheet.
const hasTimeError = Boolean(
form.place_time && form.end_time &&
form.place_time.length >= 5 && form.end_time.length >= 5 &&
form.end_time <= form.place_time,
)
const hasTimeError = endsBeforeStart(form.place_time, form.end_time)
const handleSubmit = async () => {
if (!form.name.trim()) {
@@ -287,32 +180,12 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
}
// #1152: first save of a new place warns on likely duplicates; a second
// tap with the warning showing is the explicit "add anyway".
if (!sheetPlace && !duplicateWarning) {
const dup = findDuplicateName(form, places)
if (dup) {
setDuplicateWarning(dup)
toast.warning(t('places.duplicateExists', { name: dup }))
return
}
}
if (!sheetPlace && !duplicateWarning && warnIfDuplicate(places)) return
const withExpense = expenseIntentRef.current
expenseIntentRef.current = false
setIsSaving(true)
try {
const payload = {
...form,
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
}
// #2163: the per-assignment note only travels when an assignment is in
// context AND the value actually changed — same dirty-check as the
// desktop form, so an untouched note never produces a PUT.
if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
delete payload.assignment_notes
}
const saved = await handleSavePlace(payload)
const saved = await handleSavePlace(placeFormPayload(form, pendingFiles, ctxAssignment))
const savedId = saved?.id ?? sheetPlace?.id ?? null
if (withExpense && savedId) {
onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
@@ -371,8 +244,8 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
<PlaceDetailsColumn
selection={detailsSelection}
selectedImageUrl={form.image_url}
onPickImage={(url) => setForm(prev => ({ ...prev, image_url: url ?? undefined }))}
onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))}
onPickImage={pickImage}
onAdoptDescription={adoptDescription}
hasDescription={!!form.description.trim()}
language={language}
timeFormat={timeFormat}
@@ -479,8 +352,8 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
<PlFileAttach
planner={planner}
files={pendingFiles}
onAdd={files => setPendingFiles(prev => [...prev, ...files])}
onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))}
onAdd={addFiles}
onRemove={removeFile}
/>
)}
@@ -1,8 +1,7 @@
import { useState } from 'react'
import { Ban, Check, Plus } from 'lucide-react'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { useNewCategory } from '../../../../components/Planner/usePlaceForm'
import { FIELD_CLS } from './PlSheetChrome'
import type { Category } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
interface PlCategoryPickerProps {
@@ -22,24 +21,9 @@ const PILL_BASE =
*/
export default function PlCategoryPicker({ planner, value, onChange }: PlCategoryPickerProps) {
const { t, toast, categories, tripActions } = planner
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [saving, setSaving] = useState(false)
const handleCreate = async () => {
if (!newName.trim() || saving) return
setSaving(true)
try {
const created: Category = await tripActions.addCategory({ name: newName.trim(), color: '#6366f1', icon: 'MapPin' })
onChange(String(created.id))
setNewName('')
setCreating(false)
} catch {
toast.error(t('places.categoryCreateError'))
} finally {
setSaving(false)
}
}
const {
open: creating, setOpen: setCreating, name: newName, setName: setNewName, saving, submit: handleCreate,
} = useNewCategory({ variant: 'sheet', create: tripActions.addCategory, onCreated: onChange, t, toast })
return (
<div className="flex flex-wrap gap-[6px]">
@@ -1,17 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, RotateCcw, Search } from 'lucide-react'
import { mapsApi } from '../../../../api/client'
import { useAuthStore } from '../../../../store/authStore'
import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession'
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../../../utils/apiError'
import { pointFromBox } from '../../../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../../../hooks/usePlaceSuggestions'
import { offersGoogleRetry } from '../../../../utils/placeSource'
import { usePlaceSearch, type MapsPlace } from '../../../../components/Planner/usePlaceSearch'
import { FIELD_CLS } from './PlSheetChrome'
import type { TripPlanner } from '../MTripShell'
import { usePlaceLanguage } from '../../../../hooks/usePlaceLanguage'
/** Fields a search pick can contribute to the place form. */
export interface PlSearchPick {
@@ -34,20 +25,6 @@ export interface PlSearchPick {
details?: MapsPlace
}
interface Suggestion {
placeId: string
mainText: string
secondaryText: string
/** Which of the two indexes this row came from, when the list is both. */
source?: string
lat?: number
lng?: number
/** Only on a plugin's row: its whole place, since no details lookup knows a plugin id. */
place?: MapsPlace
}
type MapsPlace = Record<string, unknown>
/** The same mark the desktop form shows, in the sheet's own tokens. */
function SourceMark({ label }: { label: string | null }) {
if (!label) return null
@@ -67,9 +44,6 @@ interface PlPlaceSearchProps {
onResolvingChange?: (resolving: boolean) => void
}
/** "48.8566, 2.3522" (also ; or whitespace separated) → direct coordinates. */
const COORD_RE = /^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/
function placeToPick(place: MapsPlace): PlSearchPick {
const s = (v: unknown) => (v == null ? undefined : String(v))
return {
@@ -90,206 +64,31 @@ function placeToPick(place: MapsPlace): PlSearchPick {
/**
* Search row of the place form: Google/OSM text search biased on the trip
* centre, autocomplete dropdown, plus Google-Maps-URL and "lat, lng" paste
* detection — the mobile counterpart of PlaceFormModal's search block.
* detection — the mobile counterpart of PlaceFormModal's search block, on the
* same search logic (usePlaceSearch).
*/
export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvingChange }: PlPlaceSearchProps) {
const { t, toast } = planner
// Place names in the language the user picked for them, the app's otherwise (#1799).
const language = usePlaceLanguage()
const [query, setQuery] = useState('')
const [results, setResults] = useState<MapsPlace[]>([])
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortRef = useRef<AbortController | null>(null)
// One Google billing session per search (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession())
// What produced the list on screen, for the shadow log. Refs, because a pick
// has to read the values that belonged to that list. Mirrors PlaceFormModal.
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
// The name the whole list carries, for rows that do not name their own index.
const [acSource, setAcSource] = useState('')
// What answered the last full search, for the line that offers Google instead.
const [searchSource, setSearchSource] = useState('')
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
const { autocomplete, sourceLabel } = usePlaceSuggestions()
const setResolving = useCallback(
(v: boolean) => {
setSearching(v)
onResolvingChange?.(v)
},
[onResolvingChange],
)
const fetchSuggestions = useCallback(
async (input: string) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
try {
const result = await autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query: input, source: result.source || 'unknown' }
setAcSource(result.source || '')
setSuggestions(result.suggestions || [])
} catch (err: unknown) {
// Superseded request — axios rejects an aborted call with CanceledError.
if (err instanceof Error && err.name === 'CanceledError') return
setSuggestions([])
}
},
[autocomplete, language, locationBias],
)
// Debounced autocomplete — URLs and coordinate pastes go to the search button.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 2 || isMapUrl(trimmed) || COORD_RE.test(trimmed)) {
// A list still on its way belongs to a query that is gone.
abortRef.current?.abort()
setSuggestions([])
return
}
debounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query, fetchSuggestions])
/**
* `pick` is present only when the place came out of a ranked list, so a
* coordinate paste or a resolved Google URL contributes no row.
*/
const applyPlace = (place: MapsPlace, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
onPick(placeToPick(place))
if (pick) {
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
const lat = Number(place.lat)
const lng = Number(place.lng)
if (meta && Number.isFinite(lat) && Number.isFinite(lng)) {
recordPlacePick({
query: meta.query,
lang: language,
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
source: `${pick.mode}:${meta.source}`,
liveRank: pick.rank,
liveCount: pick.count,
pickedName: String(place.name ?? ''),
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: (place.google_place_id as string) || (place.amap_poi_id as string) || (place.osm_id as string) || null,
})
}
}
setResults([])
setSuggestions([])
setQuery('')
}
const handleSearch = async (provider?: 'google') => {
// The retry sends the query the list came from, as the desktop form does:
// the list stays on screen while the field is edited or cleared.
const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim()
if (!trimmed) return
setSuggestions([])
// "lat, lng" paste → straight to coordinates, no lookup needed.
const coords = trimmed.match(COORD_RE)
if (coords) {
onPick({ lat: coords[1], lng: coords[2] })
setQuery('')
return
}
setResolving(true)
try {
if (!provider && isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
onPick({
name: resolved.name || undefined,
address: resolved.address || undefined,
lat: String(resolved.lat),
lng: String(resolved.lng),
google_ftid: resolved.google_ftid || undefined,
})
setQuery('')
toast.success(t('places.urlResolved'))
return
}
}
// Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche
// braucht ihn genauso, nur als Punkt statt als Kasten.
const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias), provider)
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
setResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
setResolving(false)
placesSessionRef.current.end()
}
}
const handleSelectSuggestion = async (suggestion: Suggestion) => {
// Read before the list is cleared: this is the rank the user saw.
const acPick = corePickRank(suggestions, suggestion)
abortRef.current?.abort()
setSuggestions([])
const previousQuery = query
setQuery('')
onPick({ name: suggestion.mainText })
setResolving(true)
try {
// Details are a fragile second hop (kill-switch, Overpass load) — fall
// back to the text-search path so suggestions never dead-end. (#1192)
let place: MapsPlace | null = suggestion.place ?? null
if (!place) {
try {
// Spends the session the suggestions opened.
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
if (result.place && result.place.lat != null && result.place.lng != null) place = result.place
} catch {
// fall through to text search
}
}
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
// The layer's second line is the local name, not an address, so joining
// the two makes a query nobody typed — and its first answer would be
// silently taken as the place the user picked. The row already knows
// where it is.
place = {
name: suggestion.mainText,
address: '',
lat: suggestion.lat,
lng: suggestion.lng,
osm_id: suggestion.placeId,
source: 'openstreetmap',
}
}
if (!place) {
const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias))
place = (search.places?.[0] as MapsPlace | undefined) ?? null
}
if (place) {
applyPlace(place, acPick && { mode: 'autocomplete', ...acPick })
} else {
setQuery(previousQuery)
toast.error(t('places.mapsSearchError'))
}
} catch (err: unknown) {
setQuery(previousQuery)
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
setResolving(false)
placesSessionRef.current.end()
}
}
const {
query, setQuery, results, suggestions, setSuggestions, acSource, searchSource, searching, googleAnswers,
sourceLabel, runSearch: handleSearch, pickPlace: applyPlace, selectSuggestion: handleSelectSuggestion,
} = usePlaceSearch({
variant: 'sheet',
locationBias,
t,
toast,
onPlace: place => onPick(placeToPick(place)),
onSuggestionName: name => onPick({ name }),
onMapLink: resolved => onPick({
name: resolved.name || undefined,
address: resolved.address || undefined,
lat: String(resolved.lat),
lng: String(resolved.lng),
google_ftid: resolved.google_ftid || undefined,
}),
onCoordinates: (lat, lng) => onPick({ lat, lng }),
onResolvingChange,
})
return (
<div className="relative">
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Eyebrow } from './PlSheetChrome'
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
import { timeCollisions } from '../../../../components/Planner/PlaceFormModal.helpers'
import type { Assignment } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
@@ -33,23 +34,11 @@ export default function PlTimeFields({
// The plan tab reads the day as Days does, and there End stays a plain label.
const endIsLeave = planner.activeTab === 'roadtrip'
const collisions = useMemo(() => {
if (!startTime || startTime.length < 5) return []
const current = dayAssignments.find(a => a.id === assignmentId)
if (!current) return []
const myEnd = endTime && endTime.length >= 5 ? endTime : null
return dayAssignments.filter(a => {
if (a.id === assignmentId || a.day_id !== current.day_id) return false
const otherStart = a.place?.place_time
const otherEnd = a.place?.end_time
if (!otherStart) return false
const s1 = startTime
const e1 = myEnd || startTime
const s2 = otherStart
const e2 = otherEnd || otherStart
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
})
}, [assignmentId, dayAssignments, startTime, endTime])
// The other visits of the same day whose times overlap this one's, as the desktop form warns.
const collisions = useMemo(
() => timeCollisions(assignmentId, dayAssignments, startTime, endTime),
[assignmentId, dayAssignments, startTime, endTime],
)
return (
<div className="mt-3">