Files
TREK/client/src/components/Planner/PlaceFormModal.helpers.ts
T

397 lines
15 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 type PlacePrefill = {
lat: number
lng: number
name?: string
address?: string
website?: string
phone?: string
osm_id?: string
/** A place Google knows, such as one the assistant found: the details column reads it by this id. */
google_place_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,
google_place_id: prefill.google_place_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.google_place_id || 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 }
}