feat(places): make the kind of stop editable, and enforce it on the way in

Three holes, one seam. Leaving the corridor popup by "more details" dropped the
kind and the dwell it had just worked out, so a fuel stop silently became a
numbered destination that counted in every total, in the badge and in the PDF.
Once set, the kind could never be changed back through the web UI at all. And
the REST route did not validate the field: both request schemas are open
objects, so stop_type: 'banana' was written straight to the column, where
isServiceStopType read it as false and the stop counted as a destination again.

The form now carries the field, so the popup's answer survives the trip through
it, an ordinary place can become a service stop, and a wrong kind can be taken
off. Editing keeps stop_type and deliberately leaves duration_minutes out: how
long a stay takes belongs to the rail's own dialog, and sending it from here
would overwrite what was set there.

The schemas gain the one field rather than closing: roughly 190 call sites post
whatever their form holds, and a closed object would reject all of them at once.
The doc comment claiming the list was already enforced is corrected — it was
describing an intention.

The two driving findings move into the stop's badge row in warning colours,
beside the stay and the walk from the road. All three say what this stop costs,
and one row reads as one answer rather than as notes stacked underneath.
This commit is contained in:
Maurice
2026-09-04 22:29:47 +02:00
committed by Maurice
parent e79a2591a1
commit ea9de65550
30 changed files with 205 additions and 40 deletions
@@ -1,3 +1,5 @@
import type { RoadtripStopType } from '@trek/shared'
export interface PlaceFormData {
name: string
description: string
@@ -24,6 +26,14 @@ export interface PlaceFormData {
// 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 {
@@ -1,14 +1,16 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import type { RoadtripStopType } from '@trek/shared'
import { STOP_KINDS } from '../Roadtrip/stopKinds'
import CustomSelect from '../shared/CustomSelect'
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 { useAddonStore } from '../../store/addonStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
import { useToast } from '../shared/Toast'
@@ -38,7 +40,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -88,6 +90,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
onOpenExpense,
} = 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 roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
@@ -153,6 +158,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
// Carried through every edit. Without it, opening a fuel stop to fix a typo
// submits an empty kind and turns it back into a numbered destination.
// 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 || '' } : {}),
@@ -167,6 +177,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
website: prefillCoords.website || '',
phone: prefillCoords.phone || '',
osm_id: prefillCoords.osm_id,
stop_type: prefillCoords.stop_type ?? null,
duration_minutes: prefillCoords.duration_minutes,
})
} else {
setForm(DEFAULT_FORM)
@@ -500,6 +512,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: form.lat ? Number.parseFloat(form.lat) : null,
lng: form.lng ? Number.parseFloat(form.lng) : null,
category_id: form.category_id || null,
// 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,
// Only on the way in, and only with a kind: it is the popup's suggestion for how
// long that kind of pause takes. On an edit it is left out entirely, because the
// stay belongs to the rail's dialog and sending it here would overwrite it.
...(!place && form.stop_type && form.duration_minutes
? { duration_minutes: form.duration_minutes }
: {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
}
// #2163: the per-assignment note only travels when an assignment is in
@@ -548,6 +569,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -620,6 +642,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -891,6 +914,30 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
</div>
{/* What kind of stop this is on a drive.
Under the category rather than beside it, because the two answer different
questions: a category is the traveller's own label, editable and shared across
the whole instance, while this is a fact about the place. Refuelling is not a
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
is still a fuel stop when the mode is switched on later, and this is the only
place in the web UI where the kind can be taken off again. */}
{roadtripEnabled ? (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
<CustomSelect
value={form.stop_type ?? ''}
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
placeholder={t('roadtrip.stop.none')}
options={[
{ value: '', label: t('roadtrip.stop.none') },
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
]}
size="sm"
/>
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
</div>
) : null}
{/* Category */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
@@ -1,7 +1,7 @@
import React from 'react'
import {
MapPin, CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
ParkingSquare, Shuffle,
ParkingSquare, Shuffle, Fuel, Clock,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
@@ -454,15 +454,19 @@ function RouteViaStop({ via }: { via: RouteVia }): React.ReactElement {
}
/**
* A finding about the drive rather than about a stop: too long at the wheel in one go, or
* the tank running out before anywhere to fill it.
* A finding about the drive leaving this stop: too long at the wheel in one go, or the
* tank running out before anywhere to fill it.
*
* Sits in the chain between the two stops the drive runs between, because that is where
* it happens. Neither finding names a time of day: with no stop pinned to a clock the
* cascade produces no times at all, so both are durations and distances, which exist as
* soon as the leg has routed.
* Wears the same two-part shell as the stay and the walk, in warning colours, and sits in
* the same row: all of them say what this stop costs, and one row of badges reads as one
* answer rather than as notes stacked under each other. The index is the stop the leg
* LEAVES, so the badge means "the drive from here".
*
* Neither finding names a time of day. With no stop pinned to a clock the cascade
* produces no times at all, so both are durations and distances, which exist as soon as
* the leg has routed.
*/
function DriveFinding({ warning }: { warning: ScheduleWarning }): React.ReactElement | null {
function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.ReactElement | null {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const text = warning.code === 'leg'
@@ -471,21 +475,24 @@ function DriveFinding({ warning }: { warning: ScheduleWarning }): React.ReactEle
? t('roadtrip.limit.range', { distance: formatDistance(warning.sinceKm ?? 0, distanceUnit) })
: null
if (!text) return null
const Icon = warning.code === 'range' ? Fuel : Clock
return (
<div className="grid items-stretch" style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center">
<span className="flex-1" style={RAIL_DASH} aria-hidden />
<span
className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-warning-soft"
title={text}
>
<span className="flex items-center bg-warning-soft px-1 text-warning" style={{ fontSize: FS.micro }}>
<Icon size={9} aria-hidden />
</span>
<span className="px-1.5 pb-1">
<span
className="inline-flex w-fit items-center gap-1 rounded-full bg-warning-soft px-1.5 py-0.5 font-semibold text-warning"
style={{ fontSize: FS.label }}
>
<AlertTriangle size={10} className="shrink-0" aria-hidden />
{text}
</span>
<span
className="flex items-center border-s border-warning-soft bg-surface-card px-1.5 font-semibold tabular-nums text-warning"
style={{ fontSize: FS.label }}
>
{warning.code === 'range'
? formatDistance(warning.sinceKm ?? 0, distanceUnit)
: `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`}
</span>
</div>
</span>
)
}
@@ -556,12 +563,14 @@ function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
)
}
function Stop({ stop, number, entry, late, selected, continues, onSelect, onMove, canMove, onEditStay }: {
function Stop({ stop, number, entry, late, driveFindings, selected, continues, onSelect, onMove, canMove, onEditStay }: {
stop: RoadtripStop
/** Position within the day — the same count the map badges its markers with. */
number: number
entry: ScheduleEntry | undefined
late: ScheduleWarning | undefined
/** Findings about the drive LEAVING this stop, when the limits are set and it goes over. */
driveFindings?: ScheduleWarning[]
selected: boolean
/** Whether the chain goes on below, so the marker keeps hold of the line. */
continues: boolean
@@ -629,6 +638,7 @@ function Stop({ stop, number, entry, late, selected, continues, onSelect, onMove
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
</span>
{lateText ? (
<span
@@ -784,6 +794,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
number={counted}
entry={day.schedule.entries[i]}
late={marks.find(w => w.code === 'late')}
driveFindings={day.driveWarnings.filter(w => w.index === i)}
selected={selectedAssignmentId === stop.assignmentId}
continues={i < last}
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
@@ -799,12 +810,6 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
{/* Between the two stops, which is where "somewhere on this drive" actually
is. The range finding is the only one with its remedy right beside it:
fuel and charging are the corridor search's first two categories. */}
{i < last ? day.driveWarnings.filter(w => w.index === i).map(w => (
<DriveFinding key={w.code} warning={w} />
)) : null}
{/* After the band, because a plugin halt happens on the drive it describes
rather than before setting off. */}
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
@@ -39,7 +39,8 @@ interface RoadtripStopPopupProps {
onClose: () => void
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise<void> | void
/** Opens the full place form instead, carrying what has been filled in so far. */
onMoreDetails: () => void
/** Hands over what has been picked here, so the full form opens on the same answer. */
onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void
}
/**
@@ -161,7 +162,7 @@ export default function RoadtripStopPopup({
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
<button
type="button"
onClick={onMoreDetails}
onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
{t('roadtrip.stop.moreDetails')}
+14 -3
View File
@@ -234,7 +234,7 @@ export function useTripPlanner() {
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
// Day context of the open form. Set only by the day-scoped entry points (the
// mobile day toolbar, a long-press on the mobile map); every other opener
@@ -625,6 +625,12 @@ export function useTripPlanner() {
dayId?: number | null,
/** Index within that day. Omitted, the place is appended, which is what every caller did before. */
position?: number | null,
/**
* What the corridor popup had worked out before the traveller asked for the full
* form. Without it, leaving the popup by "more details" quietly turned a fuel stop
* into a numbered destination that counts in every total.
*/
stop?: { stopType: RoadtripStopType | null; dwellMinutes: number } | null,
) => {
if (!can('place_edit', trip)) return
setPrefillCoords({
@@ -635,6 +641,8 @@ export function useTripPlanner() {
website: poi.website || undefined,
phone: poi.phone || undefined,
osm_id: poi.osm_id,
stop_type: stop?.stopType ?? null,
duration_minutes: stop?.dwellMinutes,
})
setEditingPlace(null)
setEditingAssignmentId(null)
@@ -1080,11 +1088,14 @@ export function useTripPlanner() {
}, [roadtripCorridor, can, trip])
/** Hands the draft over to the full form, keeping the day and the position it worked out. */
const stopDraftToForm = useCallback(() => {
const stopDraftToForm = useCallback((stop?: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
if (!stopDraft) return
const { poi, dayId, position } = stopDraft
setStopDraft(null)
openAddPlaceFromPoi(poi, dayId, position)
// Carries the kind and the dwell the popup had already worked out. Leaving them
// behind is what turned a fuel stop into a numbered destination on the way to the
// full form, silently and in every total.
openAddPlaceFromPoi(poi, dayId, position, stop ?? null)
}, [stopDraft, openAddPlaceFromPoi])
/**
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} زيادة',
'roadtrip.limit.range': '{distance} منذ آخر تعبئة',
'roadtrip.limit.none': 'غير محددة',
'roadtrip.stop.none': 'وجهة',
'roadtrip.stop.kindHelp': 'التوقف في الطريق يُرسم على المسار ولا يُحتسب ضمن عدد التوقفات.',
'roadtrip.poi.rest': 'استراحة',
'roadtrip.poi.campsite': 'مخيم',
'roadtrip.poi.food': 'طعام',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} a mais',
'roadtrip.limit.range': '{distance} desde o último abastecimento',
'roadtrip.limit.none': 'Nenhum definido',
'roadtrip.stop.none': 'Um destino',
'roadtrip.stop.kindHelp': 'Uma parada no caminho é desenhada no trajeto e não é contada.',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} de més',
'roadtrip.limit.range': '{distance} des de l’última càrrega',
'roadtrip.limit.none': 'Cap definit',
'roadtrip.stop.none': 'Una destinació',
'roadtrip.stop.kindHelp': 'Una parada al camí es dibuixa sobre el trajecte i no es compta.',
'roadtrip.poi.rest': 'Àrea de descans',
'roadtrip.poi.campsite': 'Càmping',
'roadtrip.poi.food': 'Menjar',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': 'o {time} více',
'roadtrip.limit.range': '{distance} od posledního tankování',
'roadtrip.limit.none': 'Nenastaveno',
'roadtrip.stop.none': 'Cíl cesty',
'roadtrip.stop.kindHelp': 'Zastávka po cestě se kreslí na trase a nepočítá se.',
'roadtrip.poi.rest': 'Odpočívadlo',
'roadtrip.poi.campsite': 'Kemp',
'roadtrip.poi.food': 'Jídlo',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} drüber',
'roadtrip.limit.range': '{distance} seit dem letzten Tanken',
'roadtrip.limit.none': 'Keine gesetzt',
'roadtrip.stop.none': 'Ein Ziel',
'roadtrip.stop.kindHelp': 'Ein Halt unterwegs wird auf der Fahrt gezeichnet und nicht mitgezählt.',
'roadtrip.poi.rest': 'Rastplatz',
'roadtrip.poi.campsite': 'Campingplatz',
'roadtrip.poi.food': 'Essen',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} over',
'roadtrip.limit.range': '{distance} since the last fill-up',
'roadtrip.limit.none': 'None set',
'roadtrip.stop.none': 'A destination',
'roadtrip.stop.kindHelp': 'A pause on the way is drawn on the drive and left out of the stop count.',
'roadtrip.poi.rest': 'Rest area',
'roadtrip.poi.campsite': 'Campsite',
'roadtrip.poi.food': 'Food',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} de más',
'roadtrip.limit.range': '{distance} desde el último repostaje',
'roadtrip.limit.none': 'Ninguno definido',
'roadtrip.stop.none': 'Un destino',
'roadtrip.stop.kindHelp': 'Una parada en el camino se dibuja sobre el trayecto y no se cuenta.',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} de trop',
'roadtrip.limit.range': '{distance} depuis le dernier plein',
'roadtrip.limit.none': 'Aucune définie',
'roadtrip.stop.none': 'Une destination',
'roadtrip.stop.kindHelp': 'Un arrêt en chemin est dessiné sur le trajet et n’est pas compté.',
'roadtrip.poi.rest': 'Aire de repos',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Restauration',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} πάνω',
'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό',
'roadtrip.limit.none': 'Δεν έχουν οριστεί',
'roadtrip.stop.none': 'Προορισμός',
'roadtrip.stop.kindHelp': 'Μια στάση στον δρόμο σχεδιάζεται πάνω στη διαδρομή και δεν προσμετράται.',
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
'roadtrip.poi.campsite': 'Κάμπινγκ',
'roadtrip.poi.food': 'Φαγητό',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} túl',
'roadtrip.limit.range': '{distance} az utolsó tankolás óta',
'roadtrip.limit.none': 'Nincs beállítva',
'roadtrip.stop.none': 'Úti cél',
'roadtrip.stop.kindHelp': 'Az útközbeni megálló az útvonalra kerül, és nem számít bele.',
'roadtrip.poi.rest': 'Pihenőhely',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Étkezés',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': 'lebih {time}',
'roadtrip.limit.range': '{distance} sejak pengisian terakhir',
'roadtrip.limit.none': 'Belum diatur',
'roadtrip.stop.none': 'Sebuah tujuan',
'roadtrip.stop.kindHelp': 'Perhentian di jalan digambar pada rute dan tidak dihitung.',
'roadtrip.poi.rest': 'Tempat istirahat',
'roadtrip.poi.campsite': 'Perkemahan',
'roadtrip.poi.food': 'Makanan',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} in più',
'roadtrip.limit.range': '{distance} dall’ultimo rifornimento',
'roadtrip.limit.none': 'Nessuno impostato',
'roadtrip.stop.none': 'Una destinazione',
'roadtrip.stop.kindHelp': 'Una sosta lungo il percorso viene disegnata sulla tratta e non viene contata.',
'roadtrip.poi.rest': 'Area di sosta',
'roadtrip.poi.campsite': 'Campeggio',
'roadtrip.poi.food': 'Ristoro',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time}超過',
'roadtrip.limit.range': '前回の給油から{distance}',
'roadtrip.limit.none': '未設定',
'roadtrip.stop.none': '目的地',
'roadtrip.stop.kindHelp': '途中の立ち寄りは走行線上に描かれ、停車数には数えません。',
'roadtrip.poi.rest': 'サービスエリア',
'roadtrip.poi.campsite': 'キャンプ場',
'roadtrip.poi.food': '食事',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} 초과',
'roadtrip.limit.range': '마지막 주유 이후 {distance}',
'roadtrip.limit.none': '설정 안 됨',
'roadtrip.stop.none': '목적지',
'roadtrip.stop.kindHelp': '경유 정차는 주행 경로에 표시되며 정차 수에 포함되지 않습니다.',
'roadtrip.poi.rest': '휴게소',
'roadtrip.poi.campsite': '캠핑장',
'roadtrip.poi.food': '식사',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} te veel',
'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt',
'roadtrip.limit.none': 'Geen ingesteld',
'roadtrip.stop.none': 'Een bestemming',
'roadtrip.stop.kindHelp': 'Een stop onderweg wordt op de rit getekend en telt niet mee.',
'roadtrip.poi.rest': 'Rustplaats',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Eten',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} ponad',
'roadtrip.limit.range': '{distance} od ostatniego tankowania',
'roadtrip.limit.none': 'Brak ustawionych',
'roadtrip.stop.none': 'Cel podróży',
'roadtrip.stop.kindHelp': 'Postój po drodze rysowany jest na trasie i nie jest liczony.',
'roadtrip.poi.rest': 'Miejsce odpoczynku',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Jedzenie',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': 'на {time} больше',
'roadtrip.limit.range': '{distance} с последней заправки',
'roadtrip.limit.none': 'Не заданы',
'roadtrip.stop.none': 'Пункт назначения',
'roadtrip.stop.kindHelp': 'Остановка в пути рисуется на маршруте и не учитывается в счёте.',
'roadtrip.poi.rest': 'Зона отдыха',
'roadtrip.poi.campsite': 'Кемпинг',
'roadtrip.poi.food': 'Еда',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} över',
'roadtrip.limit.range': '{distance} sedan senaste tankningen',
'roadtrip.limit.none': 'Inga angivna',
'roadtrip.stop.none': 'Ett resmål',
'roadtrip.stop.kindHelp': 'Ett stopp på vägen ritas på körningen och räknas inte.',
'roadtrip.poi.rest': 'Rastplats',
'roadtrip.poi.campsite': 'Campingplats',
'roadtrip.poi.food': 'Mat',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '{time} fazla',
'roadtrip.limit.range': 'son yakıt alımından beri {distance}',
'roadtrip.limit.none': 'Ayarlanmadı',
'roadtrip.stop.none': 'Bir varış noktası',
'roadtrip.stop.kindHelp': 'Yol üzerindeki mola sürüşe çizilir ve sayıma girmez.',
'roadtrip.poi.rest': 'Dinlenme alanı',
'roadtrip.poi.campsite': 'Kamp alanı',
'roadtrip.poi.food': 'Yemek',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': 'на {time} більше',
'roadtrip.limit.range': '{distance} від останньої заправки',
'roadtrip.limit.none': 'Не задано',
'roadtrip.stop.none': 'Пункт призначення',
'roadtrip.stop.kindHelp': 'Зупинка в дорозі малюється на маршруті й не рахується.',
'roadtrip.poi.rest': 'Зона відпочинку',
'roadtrip.poi.campsite': 'Кемпінг',
'roadtrip.poi.food': 'Їжа',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': 'vượt {time}',
'roadtrip.limit.range': '{distance} kể từ lần đổ nhiên liệu cuối',
'roadtrip.limit.none': 'Chưa đặt',
'roadtrip.stop.none': 'Một điểm đến',
'roadtrip.stop.kindHelp': 'Điểm dừng dọc đường được vẽ trên chặng và không tính vào số điểm dừng.',
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
'roadtrip.poi.campsite': 'Khu cắm trại',
'roadtrip.poi.food': 'Đồ ăn',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '超出 {time}',
'roadtrip.limit.range': '距上次加油 {distance}',
'roadtrip.limit.none': '未設定',
'roadtrip.stop.none': '目的地',
'roadtrip.stop.kindHelp': '途中停靠會畫在行程線上,且不計入停靠數。',
'roadtrip.poi.rest': '休息站',
'roadtrip.poi.campsite': '露營地',
'roadtrip.poi.food': '餐飲',
+2
View File
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.dayOver': '超出 {time}',
'roadtrip.limit.range': '距上次加油 {distance}',
'roadtrip.limit.none': '未设置',
'roadtrip.stop.none': '目的地',
'roadtrip.stop.kindHelp': '途中停靠会画在行程线上,且不计入停靠数。',
'roadtrip.poi.rest': '服务区',
'roadtrip.poi.campsite': '露营地',
'roadtrip.poi.food': '餐饮',
+25
View File
@@ -3,6 +3,7 @@ import {
placeBulkDeleteRequestSchema,
placeImportListRequestSchema,
placeSchema,
placeUpdateRequestSchema,
} from './place.schema';
import { describe, it, expect } from 'vitest';
@@ -52,3 +53,27 @@ describe('placeImportListRequestSchema', () => {
expect(placeImportListRequestSchema.safeParse({ url: '' }).success).toBe(false);
});
});
describe('stop_type on the write routes', () => {
it('SHARED-PLACE-020: a known kind goes through, on create and on update', () => {
expect(placeCreateRequestSchema.safeParse({ name: 'A', stop_type: 'fuel' }).success).toBe(true);
expect(placeUpdateRequestSchema.safeParse({ stop_type: 'charging' }).success).toBe(true);
});
it('SHARED-PLACE-021: an invented kind is refused rather than written to the column', () => {
// It used to go straight through: both schemas are open objects, so the value landed
// in the column and isServiceStopType then read it as false, which quietly turned a
// pause into a numbered destination.
expect(placeCreateRequestSchema.safeParse({ name: 'A', stop_type: 'banana' }).success).toBe(false);
expect(placeUpdateRequestSchema.safeParse({ stop_type: 'banana' }).success).toBe(false);
});
it('SHARED-PLACE-022: null is how a stop stops being a fuel stop', () => {
expect(placeUpdateRequestSchema.safeParse({ stop_type: null }).success).toBe(true);
});
it('SHARED-PLACE-023: every other key stays open, because roughly 190 callers rely on it', () => {
const body = { name: 'A', whatever: 1, notes: 'x', lat: 53.5, tags: [{ id: 1 }] };
expect(placeCreateRequestSchema.safeParse(body).success).toBe(true);
});
});
+26 -6
View File
@@ -79,10 +79,17 @@ export const placeRatingVoteSchema = z.object({
export type PlaceRatingVote = z.infer<typeof placeRatingVoteSchema>;
/**
* The kinds of stop a drive has, matching the categories the corridor search can look for
* (`client/src/components/Map/poiCategories.ts` and the OSM tag map in
* `server/src/nest/maps/maps.helpers.ts`). Kept as a closed list here so a typo cannot
* reach the column, and extended alongside those two when the search learns a new kind.
* The kinds of stop a drive has.
*
* The client reads the same six out of one table (`client/src/components/Roadtrip/
* stopKinds.ts`), which carries their icon, colour and usual length; that table is pinned
* against this enum by its own test, because it cannot import from here without dragging
* React into the shared package.
*
* A closed list, and enforced as one on both write routes below. It was not: the request
* schemas are open objects, so a POST with an invented kind used to be written straight
* to the column, where `isServiceStopType` then read it as false and the stop quietly
* counted as a destination.
*/
export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']);
export type RoadtripStopType = z.infer<typeof roadtripStopTypeSchema>;
@@ -173,10 +180,23 @@ export const assignmentPlaceSchema = z.object({
});
export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
/**
* Additive on one field, deliberately not a closed object.
*
* Both request schemas stay open because roughly 190 call sites still post whatever the
* form happens to hold, and closing them would reject all of them at once. Naming
* `stop_type` on top of the open object validates that one key and leaves every other
* untouched, which is the only part of the body with a fixed vocabulary.
*
* Nullable: an explicit null is how a fuel stop becomes an ordinary place again, and the
* service already reads it that way rather than as "leave alone".
*/
const stopTypeField = z.object({ stop_type: roadtripStopTypeSchema.nullable().optional() });
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) })).and(stopTypeField);
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
export const placeUpdateRequestSchema = open;
export const placeUpdateRequestSchema = open.and(stopTypeField);
export type PlaceUpdateRequest = z.infer<typeof placeUpdateRequestSchema>;
// Collaborative ratings (#1435): one 1-5 star vote per user and place.