mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): keep the shaping a traveller did, through every path that moves a stop
A via records which stop it follows by POSITION, so anything that renumbers a day's stops moves the ground under it. The road-trip rail corrects for that; almost nothing else did. reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0 in each, and re-anchoring MERGES two legs whenever the stop between them goes away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and forward again. Persisted, surviving a reload, and unrepairable except by deleting the vias. The old anchor is read before the updates and used as the primary sort key, so the vias of the earlier leg stay ahead of the ones that merged into it. Reordering the only two stops of a day deleted every via on it. A reorder is modelled as remove-then-insert, and the removal half refuses to treat a two-stop day as still having a drive — right when a stop really goes away, catastrophic when nothing was removed. Two stops is exactly the shape "follow this track" produces, up to nine vias on that one leg, gone to a single drag with no prompt and no undo. Removing a stop, reordering within a day, and assigning a place mid-day never re-anchored at all, though the helpers exist and three other paths use them. The detour then sat on a leg the traveller never chose, or on a leg that no longer existed and vanished — until a later stop brought it back bending a road nobody asked for. Reordering hands a whole new ordering rather than one move, so the anchors there follow the stop they were pinned behind instead of arithmetic on the index, which is correct for any permutation. Duplicating a trip dropped all of it: `stop_type` was missing from the place insert, and neither via nor day-track table was copied. The duplicate looked complete and quietly drove somewhere else. A failed read of the via list emptied it. That read runs after every write and feeds the routing key, so a 502, a dropped connection or a tab going offline re-routed the whole trip along the roads the traveller had steered away from, with nothing said and nothing retried — indistinguishable from having deleted them. Only a 404 or 403 empties now; anything else keeps the last known list and says it may be stale.
This commit is contained in:
@@ -472,8 +472,15 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
},
|
||||
// A longer timeout than the shared 8 s, like the other routes here that wait
|
||||
// on somebody else's service. A directions link whose stops are only named
|
||||
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
|
||||
// eight stops needs about ten seconds. Giving up at eight left the server
|
||||
// finishing the import and writing the places while the browser reported a
|
||||
// failure, and a retry then spent the whole geocoding budget again only to
|
||||
// have the dedupe skip every stop.
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
|
||||
@@ -234,6 +234,38 @@ describe('RoadtripStopPopup', () => {
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-026: a scheme-less OSM website still leaves the app', () => {
|
||||
// OSM `website` tags are routinely written without a scheme. As a raw href
|
||||
// `www.hotel.de` is a RELATIVE url, so the click navigated the planner to
|
||||
// /trips/<id>/www.hotel.de and the user lost the page they were on.
|
||||
wrap(
|
||||
<RoadtripStopPopup
|
||||
draft={draft({ poi: poi({ category: 'hotel', website: 'www.hotel-adlon.de' }), overnight })}
|
||||
{...noop}
|
||||
onSaveNight={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Website' })).toHaveAttribute('href', 'https://www.hotel-adlon.de')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-027: a website that is not a link is not rendered as one', () => {
|
||||
// Anything that is not http(s) loses the link rather than being handed to
|
||||
// the browser. The CSP stops a javascript: navigation on a stock deployment,
|
||||
// but this is defended everywhere else in the app and belongs here too.
|
||||
for (const bad of ['javascript:alert(1)', 'data:text/html,<script>', 'tel:+49301234']) {
|
||||
const { unmount } = wrap(
|
||||
<RoadtripStopPopup
|
||||
draft={draft({ poi: poi({ category: 'hotel', website: bad }), overnight })}
|
||||
{...noop}
|
||||
onSaveNight={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: 'Website' }), bad).not.toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
|
||||
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
|
||||
// leads nowhere.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
@@ -71,6 +72,13 @@ export default function RoadtripStopPopup({
|
||||
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
|
||||
}: RoadtripStopPopupProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
// Through the same guard every other external link in the app goes through.
|
||||
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
|
||||
// routinely written without a scheme — `www.hotel.de` as a raw href is a
|
||||
// RELATIVE url, so the click navigated the planner to
|
||||
// /trips/<id>/www.hotel.de instead of leaving the app. The helper adds the
|
||||
// scheme for a bare host and drops anything that is not http(s).
|
||||
const websiteHref = safeExternalHref(draft?.poi.website)
|
||||
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
|
||||
@@ -210,16 +218,16 @@ export default function RoadtripStopPopup({
|
||||
{/* What OSM knows about the house, which is the whole of what we offer: no
|
||||
price, no availability, no link into a booking portal. TREK links to maps
|
||||
and to the place's own site, never to a vendor. */}
|
||||
{draft.poi.website || draft.poi.phone ? (
|
||||
{websiteHref || draft.poi.phone ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
|
||||
{draft.poi.website ? (
|
||||
{websiteHref ? (
|
||||
<a
|
||||
href={draft.poi.website}
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t('places.website')}
|
||||
{t('places.formWebsite')}
|
||||
</a>
|
||||
) : null}
|
||||
{draft.poi.phone ? (
|
||||
|
||||
@@ -167,9 +167,21 @@ export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45
|
||||
}
|
||||
}
|
||||
|
||||
// The point the open box last swallowed. When a box closes, the next one starts
|
||||
// from it rather than from `p` alone, so the two overlap by exactly one step of
|
||||
// the walk. Starting at `p` left the stretch between them covered only by the
|
||||
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
|
||||
// padding is widthKm, so at the offered widths a band of several kilometres of
|
||||
// route fell into no box at all. Nothing downstream noticed: the panel counts
|
||||
// tiles asked and tiles answered, so it reported a finished, uncapped,
|
||||
// error-free search of a drive it had never fully looked at. "Find fuel along
|
||||
// the route" is the whole feature.
|
||||
let previous: LatLng | null = null
|
||||
|
||||
for (const p of densify(line, maxSpanDeg / 2)) {
|
||||
if (!current) {
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
previous = p
|
||||
continue
|
||||
}
|
||||
const next: Bbox = {
|
||||
@@ -181,10 +193,17 @@ export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45
|
||||
const grown = padded(next)
|
||||
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
|
||||
tiles.push(padded(current))
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
const from = previous ?? p
|
||||
current = {
|
||||
south: Math.min(from.lat, p.lat),
|
||||
north: Math.max(from.lat, p.lat),
|
||||
west: Math.min(from.lng, p.lng),
|
||||
east: Math.max(from.lng, p.lng),
|
||||
}
|
||||
} else {
|
||||
current = next
|
||||
}
|
||||
previous = p
|
||||
}
|
||||
if (current) tiles.push(padded(current))
|
||||
return tiles
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder,
|
||||
isEmptyReanchoring,
|
||||
type AnchoredVia,
|
||||
@@ -123,4 +124,54 @@ describe('reanchorAfterReorder', () => {
|
||||
it('FE-REANCHOR-016: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-017: swapping the only two stops keeps every via', () => {
|
||||
// A reorder removes nothing. Leg 0 is still leg 0 after the swap, merely
|
||||
// driven the other way round, so the anchors are already right. Routing this
|
||||
// through the removal half asked the server to delete every via of the day —
|
||||
// and two stops is exactly the shape "follow this track" produces, up to
|
||||
// nine vias on that one leg, gone to a single drag with no prompt and no
|
||||
// undo.
|
||||
const vias = [via(11, 0), via(12, 0), via(13, 0)]
|
||||
|
||||
for (const [from, to] of [[0, 1], [1, 0]] as const) {
|
||||
const plan = reanchorAfterReorder(vias, from, to, 2)
|
||||
expect(plan.remove, `${from}->${to}`).toEqual([])
|
||||
expect(isEmptyReanchoring(plan), `${from}->${to}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-019: a wholesale reorder follows the stop, not the number', () => {
|
||||
// The plan-mode drag hands a whole new ordering rather than one move, so the
|
||||
// anchors are mapped by the stop they were pinned behind. Stops A,B,C,D with
|
||||
// a via on the C->D leg (anchor 2): move A to the end and C is now at index
|
||||
// 1, so the via belongs on leg 1.
|
||||
const plan = reanchorByStopOrder([via(1, 2)], [10, 20, 30, 40], [20, 30, 40, 10])
|
||||
expect(plan.vias).toEqual([{ id: 1, after_order_index: 1 }])
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-020: a via whose stop became the last one has no leg left', () => {
|
||||
// Nothing follows the final stop, so there is no drive for the via to bend.
|
||||
const plan = reanchorByStopOrder([via(1, 0)], [10, 20, 30], [20, 30, 10])
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-021: a stop that is gone takes its vias with it', () => {
|
||||
const plan = reanchorByStopOrder([via(1, 1)], [10, 20, 30], [10, 30])
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-022: an ordering that did not actually change asks for nothing', () => {
|
||||
expect(isEmptyReanchoring(reanchorByStopOrder([via(1, 0)], [10, 20, 30], [10, 20, 30]))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-018: removing a stop from a two-stop day still clears the leg', () => {
|
||||
// The other half of the same rule, and the reason the short-circuit exists:
|
||||
// take a stop away from a two-stop day and there is no drive left, so a via
|
||||
// has nowhere to sit.
|
||||
const plan = reanchorAfterRemove([via(11, 0), via(12, 0)], 1, 2)
|
||||
expect(plan.remove).toEqual([11, 12])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +116,55 @@ describe('computeSchedule', () => {
|
||||
expect(entries[2].arrival).toBe('13:00')
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => {
|
||||
// Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be
|
||||
// placed on the day the cascade had reached so far — and that only advances
|
||||
// once a computed arrival crosses midnight, which is read before this stop's
|
||||
// arrival is known. So the stop that does the crossing had its own pin put a
|
||||
// whole day early: the rail drew a warning-coloured "+24 h" on a plan that
|
||||
// was exactly on time, and dropped the overnight marker with it.
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '20:00', dwellMinutes: 0 },
|
||||
{ anchor: '02:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(6)],
|
||||
)
|
||||
|
||||
expect(warnings.filter(w => w.code === 'late')).toEqual([])
|
||||
expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true })
|
||||
// The day still turned over, so the rail keeps its carry badge.
|
||||
expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => {
|
||||
// The other half: picking the nearest occurrence must not swallow a genuine
|
||||
// delay. Leaving at 21:00 and driving five hours arrives at 02:00, half an
|
||||
// hour after the 01:30 ferry, and that is what it says.
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '21:00', dwellMinutes: 0 },
|
||||
{ anchor: '01:30', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(5)],
|
||||
)
|
||||
expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 })
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => {
|
||||
// Nothing to be late against, but the day carried so far still applies —
|
||||
// otherwise the stop prints under a day-1 stop as though it happened first.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '20:00', dwellMinutes: 0 },
|
||||
{ anchor: '02:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(6), undefined],
|
||||
)
|
||||
expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 })
|
||||
})
|
||||
|
||||
it('reports a stop the drive cannot reach in time', () => {
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
|
||||
@@ -184,17 +184,30 @@ export interface Schedule {
|
||||
* cascade from there. When it sits before the arrival the drive implies, the plan does
|
||||
* not fit: the caller is told by how much rather than the pinned time being moved.
|
||||
*/
|
||||
const DAY_MINUTES = 24 * 60
|
||||
|
||||
function resolveArrival(
|
||||
anchor: number | null,
|
||||
cursor: number | null,
|
||||
dayOffset: number,
|
||||
): { arrival: number | null; lateBy: number | null } {
|
||||
if (anchor === null) return { arrival: cursor, lateBy: null }
|
||||
if (cursor === null) return { arrival: anchor, lateBy: null }
|
||||
const anchorToday = anchor + dayOffset * 24 * 60
|
||||
// No drive has been timed yet, so there is nothing to be late against — but the
|
||||
// day carried so far still applies, or a pinned stop after an untimed leg
|
||||
// prints under a day-1 stop as though it happened first.
|
||||
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
|
||||
// The occurrence of that wall-clock time nearest the cursor, not the one on the
|
||||
// day the cascade has reached so far. `dayOffset` only advances once a computed
|
||||
// arrival crosses midnight, and it is read before this stop's arrival is known —
|
||||
// so when the stop that crosses midnight is the pinned one, its anchor was
|
||||
// placed a whole day early and the plan was reported as 24 hours late while
|
||||
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
|
||||
// hour leg, a 09:00 pin still picks the same day and is two hours late.
|
||||
const k = Math.round((cursor - anchor) / DAY_MINUTES)
|
||||
const anchorAt = anchor + k * DAY_MINUTES
|
||||
return {
|
||||
arrival: anchorToday,
|
||||
lateBy: cursor > anchorToday + 1 ? Math.round(cursor - anchorToday) : null,
|
||||
arrival: anchorAt,
|
||||
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +548,40 @@ export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopC
|
||||
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
|
||||
* it looks AFTER the stop has been taken out.
|
||||
*/
|
||||
/**
|
||||
* Re-pin a day's vias after its stops were reordered wholesale.
|
||||
*
|
||||
* The single-move helpers above take a from/to pair, which is what the rail
|
||||
* hands them. The plan-mode drag hands a whole new ordering instead, and any
|
||||
* permutation is possible — so this maps by the stop each anchor refers to
|
||||
* rather than by arithmetic on the index. A via anchored after a stop stays
|
||||
* anchored after that same stop, wherever it ended up.
|
||||
*
|
||||
* Both lists are in the rail's index space, i.e. only stops that can be routed.
|
||||
* A via whose stop is gone, or which lands on the last stop and so has no leg
|
||||
* left to bend, is reported for removal.
|
||||
*/
|
||||
export function reanchorByStopOrder(
|
||||
vias: AnchoredVia[],
|
||||
previousIds: number[],
|
||||
nextIds: number[],
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const stopId = previousIds[via.after_order_index]
|
||||
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
|
||||
// Gone, or now the last stop of the day: either way there is no leg for it.
|
||||
if (next === -1 || next >= nextIds.length - 1) {
|
||||
remove.push(via.id)
|
||||
continue
|
||||
}
|
||||
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
export function reanchorAfterReorder(
|
||||
vias: AnchoredVia[],
|
||||
from: number,
|
||||
@@ -542,6 +589,17 @@ export function reanchorAfterReorder(
|
||||
stopCount: number,
|
||||
): Reanchoring {
|
||||
if (!vias.length || from === to) return EMPTY_REANCHORING
|
||||
// A reorder is modelled as remove-then-insert, and the removal half refuses to
|
||||
// treat a two-stop day as still having a drive: below three stops it reports
|
||||
// every via for deletion, which is right when a stop really goes away and
|
||||
// catastrophic here. Nothing is removed by a reorder — swapping the only two
|
||||
// stops leaves leg 0 exactly where it was, merely driven the other way round —
|
||||
// so the anchors are already correct and there is nothing to re-pin.
|
||||
//
|
||||
// Two stops is the shape "follow this track" produces: it lays up to nine vias
|
||||
// on the single leg between one pair, and one drag in the rail deleted all of
|
||||
// them, with no prompt and no undo.
|
||||
if (stopCount <= 2) return EMPTY_REANCHORING
|
||||
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
|
||||
const dropped = new Set(afterRemove.remove)
|
||||
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
|
||||
@@ -48,6 +48,7 @@ function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
|
||||
return {
|
||||
byDay: {},
|
||||
trackByDay: {},
|
||||
stale: false,
|
||||
add: vi.fn(),
|
||||
addMany: vi.fn().mockResolvedValue(undefined),
|
||||
move: vi.fn(),
|
||||
|
||||
@@ -43,12 +43,37 @@ describe('useRoadtripVias', () => {
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-003: an addon that is switched off means no vias, not an error', async () => {
|
||||
roadtripApi.listVias.mockRejectedValue(new Error('404'))
|
||||
// Shaped like the rejection axios actually produces, so the branch that
|
||||
// reads the status is the one under test.
|
||||
roadtripApi.listVias.mockRejectedValue({ response: { status: 404 } })
|
||||
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
|
||||
await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalled())
|
||||
expect(result.current.byDay).toEqual({})
|
||||
expect(result.current.stale).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-007: a read that fails for another reason keeps the vias it has', async () => {
|
||||
// This read runs after every write, and the via list feeds the routing key.
|
||||
// Emptying on a 502, a dropped connection or a tab that just went offline
|
||||
// re-routed the whole trip along the roads the traveller had steered away
|
||||
// from — with no toast, nothing retrying, and no way to tell it apart from
|
||||
// having deleted them.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
const { result } = renderHook(() => useRoadtripVias(7, true))
|
||||
await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
|
||||
|
||||
roadtripApi.listVias.mockRejectedValue({ response: { status: 502 } })
|
||||
await act(async () => { await result.current.move(1, 1, 54, 11) })
|
||||
|
||||
expect(result.current.byDay[1]).toHaveLength(1)
|
||||
expect(result.current.stale).toBe(true)
|
||||
|
||||
// And a read that works again clears the doubt.
|
||||
roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
|
||||
await act(async () => { await result.current.move(1, 1, 54, 11) })
|
||||
expect(result.current.stale).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-VIAS-004: adding one re-reads the list rather than guessing its id', async () => {
|
||||
|
||||
@@ -14,6 +14,16 @@ export interface RoadtripVias {
|
||||
* with the place, so nothing here can name a line that no longer exists.
|
||||
*/
|
||||
trackByDay: Record<number, RoadtripDayTrack>
|
||||
/**
|
||||
* True when the last read failed for a reason other than "there is nothing
|
||||
* here", so what is drawn may be older than what the server holds.
|
||||
*
|
||||
* The read runs after every write, and the via list feeds the routing key, so
|
||||
* emptying on a transient failure re-routed the whole trip along the roads the
|
||||
* traveller had steered away from — indistinguishable from having deleted
|
||||
* them. The list is kept and the staleness is said instead.
|
||||
*/
|
||||
stale: boolean
|
||||
add: (dayId: number, afterOrderIndex: number, lat: number, lng: number) => Promise<void>
|
||||
/**
|
||||
* Lay a chain of vias on one day, optionally clearing the legs it fills first.
|
||||
@@ -58,6 +68,14 @@ const EMPTY_TRACKS: Record<number, RoadtripDayTrack> = {}
|
||||
export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias {
|
||||
const [byDay, setByDay] = useState<Record<number, RoadtripVia[]>>(EMPTY)
|
||||
const [trackByDay, setTrackByDay] = useState<Record<number, RoadtripDayTrack>>(EMPTY_TRACKS)
|
||||
/**
|
||||
* The list on screen may be older than the server's.
|
||||
*
|
||||
* Set when a read fails for a reason that is not "there is nothing here", so
|
||||
* the rail can say the shaping it is drawing might be out of date rather than
|
||||
* quietly showing a trip that has lost its detours.
|
||||
*/
|
||||
const [stale, setStale] = useState(false)
|
||||
|
||||
const group = useCallback((vias: RoadtripVia[]) => {
|
||||
const next: Record<number, RoadtripVia[]> = {}
|
||||
@@ -73,11 +91,26 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
|
||||
const byId: Record<number, RoadtripDayTrack> = {}
|
||||
for (const track of tracks ?? []) byId[track.day_id] = track
|
||||
setTrackByDay(byId)
|
||||
} catch {
|
||||
// An instance with the addon off answers 404 here, which is not an error worth
|
||||
// reporting — it just means there are no vias to draw.
|
||||
setByDay(EMPTY)
|
||||
setTrackByDay(EMPTY_TRACKS)
|
||||
setStale(false)
|
||||
} catch (err) {
|
||||
// An instance with the addon off answers 404 here, and a caller without
|
||||
// the permission 403. Neither is worth reporting: it just means there are
|
||||
// no vias to draw, and emptying is the truthful answer.
|
||||
//
|
||||
// Anything else is not. This read runs after every write, so a 502 from a
|
||||
// proxy, a dropped connection or a tab that has just gone offline used to
|
||||
// empty the map — and because the via list feeds the routing key, the whole
|
||||
// trip was then re-routed along the roads the traveller had steered away
|
||||
// from. Nothing said so, nothing retried, and it looked exactly like
|
||||
// having deleted them. The last known list is kept instead.
|
||||
const status = (err as { response?: { status?: number } } | null)?.response?.status
|
||||
if (status === 404 || status === 403) {
|
||||
setByDay(EMPTY)
|
||||
setTrackByDay(EMPTY_TRACKS)
|
||||
setStale(false)
|
||||
return
|
||||
}
|
||||
setStale(true)
|
||||
}
|
||||
}, [tripId, active, group])
|
||||
|
||||
@@ -126,5 +159,5 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean)
|
||||
await reload()
|
||||
}, [tripId, reload])
|
||||
|
||||
return { byDay, trackByDay, add, addMany, move, remove, reanchor }
|
||||
return { byDay, trackByDay, stale, add, addMany, move, remove, reanchor }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
insertIndexForAlong,
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder,
|
||||
} from '../../components/Roadtrip/roadtripModel'
|
||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||
@@ -775,7 +776,7 @@ export function useTripPlanner() {
|
||||
try {
|
||||
await updateSettings({ [key]: value } as Partial<Settings>)
|
||||
} catch {
|
||||
toast.error(t('settings.saveFailed'))
|
||||
toast.error(t('places.saveError'))
|
||||
}
|
||||
}, [updateSettings, toast, t])
|
||||
|
||||
@@ -1395,9 +1396,25 @@ export function useTripPlanner() {
|
||||
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
// Worked out before the stop lands, the same way the road-trip popup does it:
|
||||
// once the list has shifted there is no record of which leg each via was
|
||||
// drawn for. Appending to the end moves nothing, so only a real insert needs
|
||||
// the correction — and the predicate decides which side of the new stop a
|
||||
// via falls on when it is dropped into the middle of a leg.
|
||||
const stopsBefore = roadtripStopsOf(target)
|
||||
const insertAt = position === undefined ? stopsBefore.length : position
|
||||
const place = places.find(p => p.id === placeId)
|
||||
const plan = insertAt >= stopsBefore.length || typeof place?.lat !== 'number' || typeof place?.lng !== 'number'
|
||||
? null
|
||||
: reanchorAfterInsert(
|
||||
roadtripVias.byDay[target] ?? [],
|
||||
insertAt,
|
||||
viaLiesBefore(target, { lat: place.lat, lng: place.lng }),
|
||||
)
|
||||
try {
|
||||
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position)
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
if (plan) await roadtripVias.reanchor(target, plan)
|
||||
updateRouteForDay(target)
|
||||
if (assignment?.id) {
|
||||
const capturedAssignmentId = assignment.id
|
||||
@@ -1407,15 +1424,28 @@ export function useTripPlanner() {
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, roadtripVias, roadtripStopsOf, viaLiesBefore])
|
||||
|
||||
const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => {
|
||||
const state = useTripStore.getState()
|
||||
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
|
||||
const capturedPlaceId = capturedAssignment?.place?.id
|
||||
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
|
||||
// Worked out before the delete, while the day still has the stop the vias
|
||||
// were measured against. `after_order_index` is a POSITION, so taking a stop
|
||||
// away moves the ground under every via that follows it: the anchors keep
|
||||
// their old numbers and the drive silently reverts to the road the traveller
|
||||
// steered it off, or bends a leg they never chose. This control is reachable
|
||||
// from the place inspector in both modes, and it was the one mutating path
|
||||
// that never corrected them.
|
||||
const stopsBefore = roadtripStopsOf(dayId)
|
||||
const removedAt = stopsBefore.findIndex(a => a.id === assignmentId)
|
||||
const plan = removedAt === -1
|
||||
? null
|
||||
: reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length)
|
||||
try {
|
||||
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
||||
if (plan) await roadtripVias.reanchor(dayId, plan)
|
||||
updateRouteForDay(dayId)
|
||||
if (capturedPlaceId != null) {
|
||||
const capturedDayId = dayId
|
||||
@@ -1426,14 +1456,24 @@ export function useTripPlanner() {
|
||||
}
|
||||
}
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [tripId, toast, updateRouteForDay, pushUndo])
|
||||
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf])
|
||||
|
||||
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
|
||||
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
|
||||
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
|
||||
// The rail counts anchors over routable stops only, so the plan is built in
|
||||
// that space. A drag here hands a whole new ordering rather than one move,
|
||||
// and any permutation is possible — so the anchors follow the stop they were
|
||||
// pinned behind instead of being shifted arithmetically. Without this the
|
||||
// day's detours stayed on their old numbers and the drive quietly took a
|
||||
// different road, persisted and visible to every collaborator.
|
||||
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
|
||||
const stopIdsAfter = orderedIds.filter(id => stopIdsBefore.includes(id))
|
||||
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
|
||||
try {
|
||||
tripActions.reorderAssignments(tripId, dayId, orderedIds)
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan)
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevIds
|
||||
pushUndo(t('undo.reorder'), async () => {
|
||||
@@ -1444,7 +1484,7 @@ export function useTripPlanner() {
|
||||
updateRouteForDay(dayId)
|
||||
}
|
||||
catch { toast.error(t('trip.toast.reorderError')) }
|
||||
}, [tripId, toast, pushUndo, updateRouteForDay])
|
||||
}, [tripId, toast, pushUndo, updateRouteForDay, t, roadtripVias, roadtripStopsOf])
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripActions.updateDayTitle(tripId, dayId, title) }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import type { PlaceWithTags } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service';
|
||||
import { parseDirectionsUrl } from './maps-dir.helpers';
|
||||
import { isDirectionsUrl, parseDirectionsUrl } from './maps-dir.helpers';
|
||||
import type { Place, User } from '../../types';
|
||||
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
|
||||
import { ratingAggregate } from '../common/rowShape';
|
||||
@@ -948,6 +948,18 @@ export class PlacesService {
|
||||
}
|
||||
}
|
||||
|
||||
// A route, once the redirect is followed. The dispatch upstream decides on
|
||||
// the raw URL, and a short link's path is `/<code>` — it matches nothing, so
|
||||
// every route shared from the Google Maps app arrived here and was answered
|
||||
// with "could not extract list ID", which is the complaint the directions
|
||||
// import was written to remove. The Share sheet on a phone produces exactly
|
||||
// this shape, and the box says a directions link works.
|
||||
//
|
||||
// Handed on with the resolved URL, so the hop is not made twice.
|
||||
if (isDirectionsUrl(resolvedUrl)) {
|
||||
return this.importGoogleDirections(tripId, resolvedUrl, opts);
|
||||
}
|
||||
|
||||
// Pattern: /placelists/list/{ID}
|
||||
const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/);
|
||||
if (plMatch) listId = plMatch[1];
|
||||
@@ -1155,7 +1167,12 @@ export class PlacesService {
|
||||
}
|
||||
if (!wp.name) continue;
|
||||
try {
|
||||
const hits = await this.maps.searchNominatim(wp.name);
|
||||
// Background lane, which is what this method's own contract asks of a
|
||||
// bulk caller: up to thirty sequential lookups, each taking the next
|
||||
// slot on a 1.1 s process-wide throttle. On the interactive lane every
|
||||
// other member of the instance typing in the place search box waits
|
||||
// behind the whole import.
|
||||
const hits = await this.maps.searchNominatim(wp.name, undefined, 'background');
|
||||
const hit = hits.find((h) => h.lat !== null && h.lng !== null);
|
||||
// The name from the link, not the one the geocoder answers with: somebody who
|
||||
// typed a nickname into Google should not find a street address on their trip.
|
||||
|
||||
@@ -182,8 +182,20 @@ export class RoadtripService {
|
||||
input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
|
||||
): RoadtripVia[] {
|
||||
return this.db.transaction(() => {
|
||||
// Read before writing: the OLD anchor is what says which of two merged
|
||||
// legs came first, and after the updates that information is gone.
|
||||
const before = new Map(
|
||||
this.db
|
||||
.all<{ id: number; after_order_index: number; sequence: number }>(
|
||||
'SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?',
|
||||
dayId,
|
||||
)
|
||||
.map((v) => [v.id, v]),
|
||||
);
|
||||
|
||||
for (const id of input.remove ?? []) {
|
||||
this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
|
||||
before.delete(id);
|
||||
}
|
||||
for (const via of input.vias) {
|
||||
this.db.run(
|
||||
@@ -193,6 +205,44 @@ export class RoadtripService {
|
||||
dayId,
|
||||
);
|
||||
}
|
||||
|
||||
// Renumber, because `sequence` is allocated per leg and starts at 0 in
|
||||
// each. Re-anchoring MERGES two legs whenever a stop between them goes
|
||||
// away, and their two independent series then sit side by side: leg A's
|
||||
// 0,1 next to leg B's 0,1. Everything that draws the route orders by
|
||||
// sequence, so the drive ran through A0, B0, A1, B1 — forward, back, and
|
||||
// forward again — and the wrong order was persisted, survived a reload
|
||||
// and could not be repaired except by deleting the vias.
|
||||
//
|
||||
// The old anchor is the primary key of the sort: the vias of the earlier
|
||||
// leg belong ahead of the ones from the leg that merged into it. Old
|
||||
// sequence orders within a leg, and the id settles the rest so the result
|
||||
// never depends on row order.
|
||||
const rows = this.db.all<{ id: number; after_order_index: number }>(
|
||||
'SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?',
|
||||
dayId,
|
||||
);
|
||||
const byLeg = new Map<number, { id: number }[]>();
|
||||
for (const row of rows) {
|
||||
const list = byLeg.get(row.after_order_index) ?? [];
|
||||
list.push(row);
|
||||
byLeg.set(row.after_order_index, list);
|
||||
}
|
||||
for (const list of byLeg.values()) {
|
||||
list.sort((a, b) => {
|
||||
const pa = before.get(a.id);
|
||||
const pb = before.get(b.id);
|
||||
return (
|
||||
(pa?.after_order_index ?? 0) - (pb?.after_order_index ?? 0) ||
|
||||
(pa?.sequence ?? 0) - (pb?.sequence ?? 0) ||
|
||||
a.id - b.id
|
||||
);
|
||||
});
|
||||
list.forEach((row, index) => {
|
||||
this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, row.id, dayId);
|
||||
});
|
||||
}
|
||||
|
||||
return this.listForDay(dayId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -552,17 +552,50 @@ export class TripsService {
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id,
|
||||
route_geometry, route_color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
route_geometry, route_color, stop_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPlaces) {
|
||||
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
|
||||
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
|
||||
p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id, p.route_geometry, p.route_color);
|
||||
p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id, p.route_geometry, p.route_color,
|
||||
p.stop_type);
|
||||
placeMap.set(p.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// The road-trip shaping goes with the copy. A via is not decoration: it is
|
||||
// the road the traveller chose over the one the router prefers, and a day
|
||||
// track is the line a day was fitted to. Leaving them behind gave back a
|
||||
// trip that looks complete and quietly drives somewhere else — visible
|
||||
// only once somebody starts editing the copy, with nothing to recover
|
||||
// from. Both tables are keyed by day, so they ride on `dayMap`.
|
||||
const oldVias = this.db.prepare(`
|
||||
SELECT v.* FROM roadtrip_vias v JOIN days d ON d.id = v.day_id WHERE d.trip_id = ?
|
||||
`).all(sourceTripId) as any[];
|
||||
const insertVia = this.db.prepare(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
|
||||
);
|
||||
for (const v of oldVias) {
|
||||
const newDayId = dayMap.get(v.day_id);
|
||||
if (newDayId) insertVia.run(newDayId, v.after_order_index, v.sequence, v.lat, v.lng);
|
||||
}
|
||||
|
||||
const oldTracks = this.db.prepare(`
|
||||
SELECT t.* FROM roadtrip_day_tracks t JOIN days d ON d.id = t.day_id WHERE d.trip_id = ?
|
||||
`).all(sourceTripId) as any[];
|
||||
const insertTrack = this.db.prepare(
|
||||
'INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, ?)',
|
||||
);
|
||||
for (const t of oldTracks) {
|
||||
const newDayId = dayMap.get(t.day_id);
|
||||
// The track is a place of the trip, so it has been copied too — but skip
|
||||
// the row rather than point it at the original, the way the assignment
|
||||
// and accommodation loops below skip an id they cannot map.
|
||||
const newPlaceId = placeMap.get(t.place_id);
|
||||
if (newDayId && newPlaceId) insertTrack.run(newDayId, newPlaceId, t.stray_km);
|
||||
}
|
||||
|
||||
const oldTags = this.db.prepare(`
|
||||
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
|
||||
`).all(sourceTripId) as any[];
|
||||
|
||||
@@ -204,7 +204,12 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
expect(importGoogleDirections).toHaveBeenCalledWith('5', url, { enrich: false, userId: 1 });
|
||||
expect(importGoogleList).not.toHaveBeenCalled();
|
||||
});
|
||||
it('leaves a list link with the list importer', async () => {
|
||||
it('sends a short link to the list importer, which knows where it really goes', async () => {
|
||||
// A short link's path is `/<code>`, so nothing about the raw URL says
|
||||
// whether it is a route or a list. The dispatch cannot know before the
|
||||
// redirect is followed, and the list importer already follows it — so it
|
||||
// is the one that hands a route on. Deciding here would mean a second
|
||||
// network hop and a second copy of the SSRF handling.
|
||||
const importGoogleDirections = vi.fn();
|
||||
const importGoogleList = vi.fn().mockResolvedValue({ places: [], listName: 'L', skipped: 0 });
|
||||
const s = svc({ importGoogleDirections, importGoogleList, broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
|
||||
@@ -202,6 +202,42 @@ describe('PlacesService.importGoogleDirections', () => {
|
||||
expect((result as { error: string }).error).toMatch(/Share button/);
|
||||
});
|
||||
|
||||
it('PLACES-DIR-012: the bulk geocoding yields to whoever is typing', async () => {
|
||||
// searchNominatim's own contract asks a bulk caller for the background
|
||||
// lane, naming this exact shape: up to thirty sequential lookups, each
|
||||
// taking the next slot on a 1.1 s process-wide throttle. On the interactive
|
||||
// lane every other member of the instance typing in the place search box
|
||||
// queues behind the whole import.
|
||||
const search = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as MapsService['searchNominatim'];
|
||||
await svc(search).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden');
|
||||
|
||||
expect(search).toHaveBeenCalled();
|
||||
for (const call of (search as unknown as { mock: { calls: unknown[][] } }).mock.calls) {
|
||||
expect(call[2]).toBe('background');
|
||||
}
|
||||
});
|
||||
|
||||
it('PLACES-DIR-011: a route shared as a short link is imported as a route', async () => {
|
||||
// The path of a short link is `/<code>`: nothing in it says route or list,
|
||||
// so the dispatch upstream cannot tell and sent every one of them to the
|
||||
// list importer, which answered "could not extract list ID" — the complaint
|
||||
// this whole import was written to remove. The Share sheet in the Google
|
||||
// Maps phone app produces exactly this shape, and the box beside it says a
|
||||
// directions link works.
|
||||
safeFetchFollow.mockResolvedValue({
|
||||
url: 'https://www.google.com/maps/dir/52.52,13.405/51.05,13.74',
|
||||
} as never);
|
||||
|
||||
const result = await svc(geocoder()).importGoogleList(tripId, 'https://maps.app.goo.gl/aBcDeF12345');
|
||||
|
||||
expect('error' in result).toBe(false);
|
||||
if ('error' in result) return;
|
||||
expect(result.places).toHaveLength(2);
|
||||
// Resolved once, not once per importer: the route is handed on with the URL
|
||||
// the redirect already produced.
|
||||
expect(safeFetchFollow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('PLACES-DIR-010: a route where only one stop can be placed is not half an import', async () => {
|
||||
const search = vi.fn(async () => []) as unknown as MapsService['searchNominatim'];
|
||||
const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else';
|
||||
|
||||
@@ -151,6 +151,47 @@ describe('RoadtripService', () => {
|
||||
expect(after.map(v => v.after_order_index)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', () => {
|
||||
// The case none of the four above reach: they all re-anchor onto legs that
|
||||
// are empty. `sequence` is allocated per leg and starts at 0 in each, so
|
||||
// merging two populated legs put leg A's 0,1 beside leg B's 0,1 — and
|
||||
// everything that draws the route orders by sequence, so the drive ran A0,
|
||||
// B0, A1, B1: forward, back, and forward again. Persisted, surviving a
|
||||
// reload, and unrepairable except by deleting the vias.
|
||||
const a0 = service.create(1, { after_order_index: 1, lat: 50.0, lng: 10 });
|
||||
const a1 = service.create(1, { after_order_index: 1, lat: 50.1, lng: 10 });
|
||||
const b0 = service.create(1, { after_order_index: 2, lat: 50.2, lng: 10 });
|
||||
const b1 = service.create(1, { after_order_index: 2, lat: 50.3, lng: 10 });
|
||||
expect([a0.sequence, a1.sequence, b0.sequence, b1.sequence]).toEqual([0, 1, 0, 1]);
|
||||
|
||||
// The stop between the two legs is removed, so leg 2 merges into leg 1 —
|
||||
// exactly what reanchorAfterRemove computes on the client.
|
||||
const after = service.reanchor(1, {
|
||||
vias: [{ id: b0.id, after_order_index: 1 }, { id: b1.id, after_order_index: 1 }],
|
||||
});
|
||||
|
||||
const onLeg = after.filter(v => v.after_order_index === 1).sort((x, y) => x.sequence - y.sequence);
|
||||
expect(onLeg.map(v => v.id)).toEqual([a0.id, a1.id, b0.id, b1.id]);
|
||||
// Strictly increasing across the whole leg, with no value used twice.
|
||||
expect(onLeg.map(v => v.sequence)).toEqual([0, 1, 2, 3]);
|
||||
// Which is the geographic order the day is actually driven in.
|
||||
expect(onLeg.map(v => v.lat)).toEqual([50.0, 50.1, 50.2, 50.3]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', () => {
|
||||
const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const second = service.create(1, { after_order_index: 0, lat: 54, lng: 11 });
|
||||
const elsewhere = service.create(1, { after_order_index: 3, lat: 55, lng: 12 });
|
||||
|
||||
const after = service.reanchor(1, { vias: [{ id: elsewhere.id, after_order_index: 2 }] });
|
||||
|
||||
const leg0 = after.filter(v => v.after_order_index === 0).sort((x, y) => x.sequence - y.sequence);
|
||||
expect(leg0.map(v => v.id)).toEqual([first.id, second.id]);
|
||||
expect(leg0.map(v => v.sequence)).toEqual([0, 1]);
|
||||
// And the one that moved is alone on its new leg, numbered from zero.
|
||||
expect(after.find(v => v.id === elsewhere.id)?.sequence).toBe(0);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', () => {
|
||||
const vias = service.createMany(1, { vias: [
|
||||
{ after_order_index: 0, lat: 53.0, lng: 10.0 },
|
||||
|
||||
@@ -754,6 +754,52 @@ describe('folded trip CRUD', () => {
|
||||
expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-061: copy carries the road-trip shaping, not just the places', () => {
|
||||
// A via is the road the traveller chose over the one the router prefers, and
|
||||
// a day track is the line a day was fitted to. Leaving them behind gave back
|
||||
// a trip that looks complete and quietly drives somewhere else — noticed
|
||||
// only once somebody edits the copy, with nothing left to recover from.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Norway', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
const days = getDays(trip.id);
|
||||
const stop = createPlace(testDb, trip.id, { name: 'Geiranger' });
|
||||
const track = createPlace(testDb, trip.id, { name: 'Scenic route' });
|
||||
testDb.prepare("UPDATE places SET stop_type = 'fuel' WHERE id = ?").run(stop.id);
|
||||
testDb.prepare("UPDATE places SET route_geometry = '[[1,2],[3,4]]' WHERE id = ?").run(track.id);
|
||||
createDayAssignment(testDb, days[0].id, stop.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 62.1, 7.2), (?, 0, 1, 62.2, 7.3)',
|
||||
).run(days[0].id, days[0].id);
|
||||
testDb.prepare('INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, 1.5)')
|
||||
.run(days[0].id, track.id);
|
||||
|
||||
const newTripId = svc.copy(trip.id, user.id, 'Clone');
|
||||
const newDays = getDays(newTripId);
|
||||
|
||||
// The kind of stop each place is survives the copy.
|
||||
const copiedStop = testDb.prepare("SELECT stop_type FROM places WHERE trip_id = ? AND name = 'Geiranger'")
|
||||
.get(newTripId) as { stop_type: string | null };
|
||||
expect(copiedStop.stop_type).toBe('fuel');
|
||||
|
||||
const vias = testDb.prepare('SELECT after_order_index, sequence, lat, lng FROM roadtrip_vias WHERE day_id = ? ORDER BY sequence')
|
||||
.all(newDays[0].id) as { after_order_index: number; sequence: number; lat: number; lng: number }[];
|
||||
expect(vias).toEqual([
|
||||
{ after_order_index: 0, sequence: 0, lat: 62.1, lng: 7.2 },
|
||||
{ after_order_index: 0, sequence: 1, lat: 62.2, lng: 7.3 },
|
||||
]);
|
||||
|
||||
// The track points at the COPY's place, never back at the original.
|
||||
const copiedTrack = testDb.prepare('SELECT place_id, stray_km FROM roadtrip_day_tracks WHERE day_id = ?')
|
||||
.get(newDays[0].id) as { place_id: number; stray_km: number };
|
||||
const copiedTrackPlace = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Scenic route'")
|
||||
.get(newTripId) as { id: number };
|
||||
expect(copiedTrack.place_id).toBe(copiedTrackPlace.id);
|
||||
expect(copiedTrack.stray_km).toBe(1.5);
|
||||
|
||||
// And the original keeps exactly what it had.
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias WHERE day_id = ?').get(days[0].id)).toEqual({ c: 2 });
|
||||
});
|
||||
|
||||
it('TRIP-SVC-060: copying a trip keeps a staged booking staged', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
|
||||
Reference in New Issue
Block a user