mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
71289ba43e
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.
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
|
|
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
|
|
|
|
export interface PlaceFormData {
|
|
name: string
|
|
description: string
|
|
address: string
|
|
lat: string
|
|
lng: string
|
|
category_id: string
|
|
place_time: string
|
|
end_time: string
|
|
notes: string
|
|
transport_mode: string
|
|
website: string
|
|
// Populated from a maps-search pick, and typed in by hand since #2472.
|
|
phone?: string
|
|
// Typed in by hand (#2472). Absent from DEFAULT_FORM like phone: the mobile sheet
|
|
// shares this type and never sets them, and a missing key writes nothing.
|
|
email?: string
|
|
// The place's own hours as JSON text, seven days Monday first; '' clears them.
|
|
opening_hours?: string
|
|
google_place_id?: string
|
|
google_ftid?: string
|
|
osm_id?: string
|
|
amap_poi_id?: string
|
|
// Hero image picked from the detail column. Optional and absent from
|
|
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
|
|
// it, and places.service already writes image_url through on create/update.
|
|
image_url?: string
|
|
// Day-specific note on the in-context assignment (#2163). Only hydrated when
|
|
// the form opened with an assignment in context; both forms drop it from the
|
|
// submit payload when unchanged, and useTripPlanner strips it off the place
|
|
// update and PUTs it per assignment instead.
|
|
assignment_notes?: string
|
|
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
|
|
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
|
|
// place created outside a road trip has no opinion about fuel stops.
|
|
stop_type?: RoadtripStopType | null
|
|
// Offered only while creating, and only alongside a stop_type: the corridor popup
|
|
// suggests how long that kind of pause usually takes, and the full form should not
|
|
// throw the suggestion away on the way through. Editing a stay is the rail's job.
|
|
duration_minutes?: number
|
|
}
|
|
|
|
export function isGoogleMapsUrl(input: string): boolean {
|
|
try {
|
|
const { hostname, pathname } = new URL(input.trim())
|
|
const h = hostname.toLowerCase()
|
|
// maps.app.goo.gl, goo.gl/maps
|
|
if (h === 'maps.app.goo.gl') return true
|
|
if (h === 'goo.gl' && pathname.startsWith('/maps')) return true
|
|
// maps.google.* (e.g. maps.google.com, maps.google.co.uk)
|
|
// Must be maps.google.<tld> or maps.google.<sld>.<tld> — reject maps.google.evil.com
|
|
if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true
|
|
// google.*/maps (e.g. google.com/maps, www.google.co.uk/maps)
|
|
const bare = h.startsWith('www.') ? h.slice(4) : h
|
|
if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true
|
|
return false
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Amap's own hosts, the same set the server resolves (amap.provider.ts). An
|
|
* exact list rather than a shape: Amap has no ccTLD family, so a pattern would
|
|
* only widen what the search box hands to the resolver.
|
|
*/
|
|
const AMAP_HOSTS = new Set(['amap.com', 'www.amap.com', 'uri.amap.com', 'wb.amap.com', 'surl.amap.com', 'gaode.com', 'www.gaode.com'])
|
|
|
|
export function isAmapUrl(input: string): boolean {
|
|
try {
|
|
return AMAP_HOSTS.has(new URL(input.trim()).hostname.toLowerCase())
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** A pasted link the server can turn into a place, from either provider. */
|
|
export function isMapUrl(input: string): boolean {
|
|
return isGoogleMapsUrl(input) || isAmapUrl(input)
|
|
}
|
|
|
|
export const DEFAULT_FORM: PlaceFormData = {
|
|
name: '',
|
|
description: '',
|
|
address: '',
|
|
lat: '',
|
|
lng: '',
|
|
category_id: '',
|
|
place_time: '',
|
|
end_time: '',
|
|
notes: '',
|
|
transport_mode: 'walking',
|
|
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.
|
|
*/
|
|
export const RESULT_FIELDS = [
|
|
'name',
|
|
'address',
|
|
'lat',
|
|
'lng',
|
|
'google_place_id',
|
|
'google_ftid',
|
|
'osm_id',
|
|
'amap_poi_id',
|
|
'website',
|
|
'phone',
|
|
] as const
|
|
|
|
export type ResultField = (typeof RESULT_FIELDS)[number]
|
|
|
|
/**
|
|
* Folds a picked search result into the form.
|
|
*
|
|
* The obvious version is `result.x || prev.x`, and it has a bug that is easy to
|
|
* miss and hard to spot as a user: that expression cannot tell "the user typed
|
|
* this" from "the previous search result wrote this". Pick Hamburg Airport,
|
|
* then pick Berlin Hauptbahnhof — which has no website in OpenStreetMap — and
|
|
* the airport's website is still sitting in the field. Save it and the station
|
|
* now links to an airport.
|
|
*
|
|
* So the caller tracks which fields it filled in itself. A field the last pick
|
|
* wrote belongs to the last place and is cleared when the new one says nothing
|
|
* about it; a field the user typed survives untouched. `autoFilled` is mutated
|
|
* in place — it is the caller's record of what it owns.
|
|
*/
|
|
export function mergeResult(
|
|
prev: PlaceFormData,
|
|
result: Record<string, unknown>,
|
|
autoFilled: Set<ResultField>,
|
|
): PlaceFormData {
|
|
const next = { ...prev } as PlaceFormData & Record<string, string | undefined>
|
|
|
|
for (const field of RESULT_FIELDS) {
|
|
const raw = result[field]
|
|
const value = raw == null ? '' : String(raw)
|
|
|
|
if (value) {
|
|
next[field] = value
|
|
autoFilled.add(field)
|
|
} else if (autoFilled.has(field)) {
|
|
// Belonged to the place that is no longer selected.
|
|
next[field] = ''
|
|
autoFilled.delete(field)
|
|
}
|
|
// Otherwise the user put it there; leave it alone.
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
|
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
|
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
|
const DUP_COORD_TOLERANCE = 0.0001
|
|
|
|
/**
|
|
* Which resemblances count as evidence.
|
|
*
|
|
* The defaults are the ordinary add place and are not to be changed. A stop on a drive
|
|
* asks a different question: brand names repeat along a motorway and the map record does
|
|
* not, so it turns the name off and the OSM object on.
|
|
*/
|
|
export interface DuplicateRules {
|
|
byName?: boolean
|
|
byOsmId?: boolean
|
|
}
|
|
|
|
export function findDuplicatePlace(
|
|
form: PlaceFormData,
|
|
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null; osm_id?: string | null }[],
|
|
rules: DuplicateRules = {},
|
|
): { name?: string | null } | null {
|
|
const { byName = true, byOsmId = false } = rules
|
|
const name = (form.name || '').trim().toLowerCase()
|
|
const gid = (form.google_place_id || '').trim()
|
|
const osmId = (form.osm_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
|
|
if (byOsmId && osmId && p.osm_id && p.osm_id === osmId) return p
|
|
if (byName && name && p.name && p.name.trim().toLowerCase() === name) return p
|
|
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
|
|
}
|
|
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
|
|
* takes as typed.
|
|
*/
|
|
export function parseCoordinatePair(text: string): [string, string] | null {
|
|
const match = text.trim().match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
|
return match ? [match[1], match[2]] : null
|
|
}
|
|
|
|
/** Both clocks are complete and the end is not after the start. */
|
|
export function endsBeforeStart(start: string, end: string): boolean {
|
|
return !!start && !!end && start.length >= 5 && end.length >= 5 && end <= start
|
|
}
|
|
|
|
/**
|
|
* The other visits of the same day whose times overlap this one's.
|
|
*
|
|
* An open end collapses to a point in time, and a visit without a start takes no
|
|
* part: it has no place on the clock to overlap anything.
|
|
*/
|
|
export function timeCollisions(
|
|
assignmentId: number | null,
|
|
dayAssignments: Pick<Assignment, 'id' | 'day_id' | 'place'>[],
|
|
start: string,
|
|
end: string,
|
|
): Pick<Assignment, 'id' | 'day_id' | 'place'>[] {
|
|
if (!assignmentId || !start || start.length < 5) return []
|
|
const current = dayAssignments.find(a => a.id === assignmentId)
|
|
if (!current) return []
|
|
const myEnd = end && end.length >= 5 ? end : null
|
|
return dayAssignments.filter(a => {
|
|
if (a.id === assignmentId) return false
|
|
if (a.day_id !== current.day_id) return false
|
|
const aStart = a.place?.place_time
|
|
const aEnd = a.place?.end_time
|
|
if (!aStart) return false
|
|
// Two intervals overlap if start < otherEnd AND otherStart < end
|
|
const s1 = start, e1 = myEnd || start
|
|
const s2 = aStart, e2 = aEnd || aStart
|
|
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The pin the form holds, when both fields are real coordinates: what "places
|
|
* near here" asks about (#976). Empty or half-typed fields are no pin.
|
|
*/
|
|
export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number; lng: number } | null {
|
|
if (!form.lat.trim() || !form.lng.trim()) return null
|
|
const lat = Number(form.lat)
|
|
const lng = Number(form.lng)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
|
|
return { lat, lng }
|
|
}
|