refactor(client): the day plan's assignment edits move into useDayPlanEdits

Putting a place on a day, moving a stop to another day, taking one off,
reordering a day with the vias it carries along, renaming a day and
reordering the days move out of useTripPlanner into useDayPlanEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
day plan's edits used to sit, ahead of the day add, delete and clear
wiring, so the planner returns the same object and behaves exactly as
before.
This commit is contained in:
Maurice
2026-10-08 03:53:41 +00:00
parent 7e6af9c8fc
commit f9d0f960bc
3 changed files with 495 additions and 149 deletions
@@ -0,0 +1,297 @@
// FE-TP-DAYPLAN-001 to FE-TP-DAYPLAN-010
//
// The day plan's edits, driven straight through useDayPlanEdits. The store holds the
// day lists the remove and reorder handlers capture for their undo, every write goes
// to a recorded stand-in for the store's actions, and the road trip helpers are small
// stand-ins, so each case can read which vias were carried along.
import { act, renderHook } from '@testing-library/react'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Assignment } from '../../types'
import type { Translate } from './plannerTypes'
import type { RoadtripFeed } from './useRoadtripFeed'
import { useDayPlanEdits } from './useDayPlanEdits'
type Options = Parameters<typeof useDayPlanEdits>[0]
type Undo = () => Promise<void>
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
const t = ((key: string) => key) as Translate
const lake = buildPlace({ id: 1, name: 'Lake', lat: 50, lng: 10, place_time: '08:00' })
const castle = buildPlace({ id: 2, name: 'Castle', lat: 50, lng: 11, place_time: '12:00' })
const summit = buildPlace({ id: 3, name: 'Summit', lat: 50, lng: 12, tour_place_id: 3 })
const museum = buildPlace({ id: 4, name: 'Museum', lat: 50, lng: 10.5, place_time: '10:00' })
const day10: Assignment[] = [
buildAssignment({ id: 101, day_id: 10, place: lake, order_index: 0 }),
buildAssignment({ id: 102, day_id: 10, place: castle, order_index: 1 }),
buildAssignment({ id: 103, day_id: 10, place: summit, order_index: 2 }),
]
const day11: Assignment[] = [buildAssignment({ id: 111, day_id: 11, place: summit, order_index: 0 })]
const reanchoring = { vias: [{ id: 7, after_order_index: 2 }], remove: [] }
function makeActions() {
return {
assignPlaceToDay: vi.fn(async (): Promise<{ id: number } | undefined> => ({ id: 555 })),
removeAssignment: vi.fn(async () => undefined),
moveAssignment: vi.fn(async () => undefined),
reorderAssignments: vi.fn(async () => undefined),
updateDayTitle: vi.fn(async () => undefined),
reorderDays: vi.fn(async () => undefined),
}
}
let actions: ReturnType<typeof makeActions>
let vias: { byDay: Record<number, { id: number; after_order_index: number; lat: number; lng: number }[]>; reanchor: ReturnType<typeof vi.fn> }
let fixed: Options
function renderEdits(over: Partial<Options> = {}) {
const options = { ...fixed, ...over }
return renderHook(() => useDayPlanEdits(options))
}
const lastUndo = (): Undo => vi.mocked(fixed.pushUndo).mock.lastCall![1] as Undo
// The reorders hand their write on without returning it, so a case waits for the chain to settle.
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
beforeEach(() => {
vi.clearAllMocks()
resetAllStores()
actions = makeActions()
vias = { byDay: { 10: [{ id: 7, after_order_index: 0, lat: 50, lng: 10.2 }] }, reanchor: vi.fn(async () => undefined) }
const assignments = { 10: day10, 11: day11 }
useTripStore.setState({
places: [lake, castle, summit, museum],
days: [buildDay({ id: 10, day_number: 1 }), buildDay({ id: 11, day_number: 2 }), buildDay({ id: 12, day_number: 3 })],
assignments,
})
fixed = {
tripId: 42, toast, t,
tripActions: { ...useTripStore.getState(), ...actions } as unknown as TripStoreState,
places: [lake, castle, summit, museum], storedAssignments: assignments, tripAccommodations: [], selectedDayId: 10,
pushUndo: vi.fn(), reloadTourPlaceIds: vi.fn(async () => undefined), updateRouteForDay: vi.fn(async () => undefined),
isTourPlace: (placeId: number) => placeId === 3,
roadtripVias: vias as unknown as RoadtripFeed['roadtripVias'],
roadtripStopsOf: (dayId: number) => (dayId === 10 ? day10 : []),
viasAfterInsert: vi.fn(() => null),
}
})
describe('useDayPlanEdits', () => {
it('FE-TP-DAYPLAN-001: with no day chosen nothing is assigned, and a tour already on the day is not added twice', async () => {
const { result } = renderEdits({ selectedDayId: null })
let assigned: boolean | undefined
await act(async () => { assigned = await result.current.handleAssignToDay(1) })
expect(assigned).toBe(false)
expect(toast.error).toHaveBeenCalledWith('trip.toast.selectDay')
await act(async () => { assigned = await result.current.handleAssignToDay(3, 11) })
expect(assigned).toBe(false)
expect(actions.assignPlaceToDay).not.toHaveBeenCalled()
})
it('FE-TP-DAYPLAN-002: a place with a start of its own is stored behind the row it is drawn after, and carries the vias along', async () => {
vi.mocked(fixed.viasAfterInsert).mockReturnValue(reanchoring)
const { result } = renderEdits()
let assigned: boolean | undefined
await act(async () => { assigned = await result.current.handleAssignToDay(4) })
expect(assigned).toBe(true)
// Behind the lake at eight, ahead of the castle at noon.
expect(fixed.viasAfterInsert).toHaveBeenCalledWith(10, 1, museum)
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 4, 1)
expect(toast.success).toHaveBeenCalledWith('trip.toast.assignedToDay')
expect(vias.reanchor).toHaveBeenCalledWith(10, reanchoring)
expect(fixed.updateRouteForDay).toHaveBeenCalledWith(10)
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.assignPlace', expect.any(Function), [10], [4])
expect(fixed.reloadTourPlaceIds).not.toHaveBeenCalled()
await act(async () => { await lastUndo()() })
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 10, 555)
})
it('FE-TP-DAYPLAN-003: a tour put on another day reloads the tours, and so does its undo', async () => {
const { result } = renderEdits()
await act(async () => { await result.current.handleAssignToDay(3, 12, 0) })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 12, 3, 0)
expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(1)
expect(vias.reanchor).not.toHaveBeenCalled()
await act(async () => { await lastUndo()() })
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 12, 555)
expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(2)
})
it('FE-TP-DAYPLAN-004: a failed assignment is reported whatever was thrown, a failed via move still keeps the stop', async () => {
const { result } = renderEdits()
let assigned: boolean | undefined
actions.assignPlaceToDay.mockRejectedValueOnce(new Error('day.full'))
await act(async () => { assigned = await result.current.handleAssignToDay(1) })
expect(assigned).toBe(false)
expect(toast.error).toHaveBeenLastCalledWith('day.full')
actions.assignPlaceToDay.mockRejectedValueOnce('nope')
await act(async () => { assigned = await result.current.handleAssignToDay(1) })
expect(toast.error).toHaveBeenLastCalledWith('common.unknownError')
expect(fixed.pushUndo).not.toHaveBeenCalled()
// A write the store answered without a row is not offered for undo.
actions.assignPlaceToDay.mockResolvedValueOnce(undefined)
await act(async () => { assigned = await result.current.handleAssignToDay(1) })
expect(assigned).toBe(true)
expect(fixed.pushUndo).not.toHaveBeenCalled()
vi.mocked(fixed.viasAfterInsert).mockReturnValue(reanchoring)
vias.reanchor.mockRejectedValueOnce(new Error('vias.locked')).mockRejectedValueOnce('nope')
await act(async () => { assigned = await result.current.handleAssignToDay(1) })
expect(assigned).toBe(true)
expect(toast.error).toHaveBeenLastCalledWith('vias.locked')
await act(async () => { await result.current.handleAssignToDay(1) })
expect(toast.error).toHaveBeenLastCalledWith('common.unknownError')
expect(fixed.updateRouteForDay).toHaveBeenLastCalledWith(10)
})
it('FE-TP-DAYPLAN-005: a stop moved to another day takes its place along to the via plan, and a failed move rejects', async () => {
vi.mocked(fixed.viasAfterInsert).mockReturnValueOnce(reanchoring)
const { result } = renderEdits()
await act(async () => { await result.current.handleMoveToDay(101, 10, 11, 0) })
expect(fixed.viasAfterInsert).toHaveBeenCalledWith(11, 0, lake)
expect(actions.moveAssignment).toHaveBeenCalledWith(42, 101, 10, 11, 0)
expect(vias.reanchor).toHaveBeenCalledWith(11, reanchoring)
// A row the stored list does not know has no place, and an append moves no via.
await act(async () => { await result.current.handleMoveToDay(999, 12, 11) })
expect(fixed.viasAfterInsert).toHaveBeenLastCalledWith(11, undefined, undefined)
expect(vias.reanchor).toHaveBeenCalledTimes(1)
actions.moveAssignment.mockRejectedValueOnce(new Error('offline'))
await expect(act(() => result.current.handleMoveToDay(101, 10, 11))).rejects.toThrow('offline')
})
it('FE-TP-DAYPLAN-006: removing a stop moves the vias behind it, and its undo puts it back where it stood', async () => {
vias.byDay[10] = [{ id: 7, after_order_index: 1, lat: 50, lng: 11.5 }]
const { result } = renderEdits()
await act(async () => { await result.current.handleRemoveAssignment(10, 102) })
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 10, 102)
// The via behind the castle now follows the lake, the stop ahead of the one taken away.
expect(vias.reanchor).toHaveBeenCalledWith(10, { vias: [{ id: 7, after_order_index: 0 }], remove: [] })
expect(fixed.updateRouteForDay).toHaveBeenCalledWith(10)
expect(fixed.reloadTourPlaceIds).not.toHaveBeenCalled()
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.removeAssignment', expect.any(Function), [10], [2])
await act(async () => { await lastUndo()() })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 2, 1)
})
it('FE-TP-DAYPLAN-007: a removed tour reloads the tours both ways, and an unknown row leaves the vias and the undo alone', async () => {
const { result } = renderEdits()
await act(async () => { await result.current.handleRemoveAssignment(11, 111) })
expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(1)
await act(async () => { await lastUndo()() })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 11, 3, 0)
expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(2)
vi.mocked(fixed.pushUndo).mockClear()
await act(async () => { await result.current.handleRemoveAssignment(12, 999) })
expect(actions.removeAssignment).toHaveBeenLastCalledWith(42, 12, 999)
expect(vias.reanchor).not.toHaveBeenCalled()
expect(fixed.pushUndo).not.toHaveBeenCalled()
actions.removeAssignment.mockRejectedValueOnce(new Error('gone')).mockRejectedValueOnce('nope')
await act(async () => { await result.current.handleRemoveAssignment(10, 101) })
expect(toast.error).toHaveBeenLastCalledWith('gone')
await act(async () => { await result.current.handleRemoveAssignment(10, 101) })
expect(toast.error).toHaveBeenLastCalledWith('common.unknownError')
})
it('FE-TP-DAYPLAN-008: a reordered day keeps its hidden rows in place, carries its vias and undoes to the old order', async () => {
vias.byDay[10] = [{ id: 7, after_order_index: 1, lat: 50, lng: 11.5 }]
const { result } = renderEdits()
// The list hides the castle; the lake and the summit swap around it.
await act(async () => { result.current.handleReorder(10, [103, 101]) })
await settle()
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 10, [103, 102, 101])
expect(fixed.updateRouteForDay).toHaveBeenCalledWith(10)
// The via drawn behind the castle stays behind it, and the castle is still second.
expect(vias.reanchor).not.toHaveBeenCalled()
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function), [10], [1, 2, 3])
await act(async () => { await lastUndo()() })
expect(actions.reorderAssignments).toHaveBeenLastCalledWith(42, 10, [101, 102, 103])
// Behind the lake, which is now last, the via has no leg left and goes.
vias.byDay[10] = [{ id: 7, after_order_index: 0, lat: 50, lng: 10.2 }]
const moved = renderEdits()
await act(async () => { moved.result.current.handleReorder(10, [102, 103, 101]) })
await settle()
expect(vias.reanchor).toHaveBeenCalledWith(10, { vias: [], remove: [7] })
})
it('FE-TP-DAYPLAN-009: a reorder that fails says so, however it fails', async () => {
const { result } = renderEdits()
actions.reorderAssignments.mockRejectedValueOnce(new Error('conflict')).mockRejectedValueOnce('nope')
await act(async () => { result.current.handleReorder(10, [101, 102, 103]) })
await settle()
expect(toast.error).toHaveBeenLastCalledWith('conflict')
await act(async () => { result.current.handleReorder(10, [101, 102, 103]) })
await settle()
expect(toast.error).toHaveBeenLastCalledWith('trip.toast.reorderError')
// A write that throws before it promises anything is caught too.
actions.reorderAssignments.mockImplementationOnce(() => { throw new Error('sync') })
toast.error.mockClear()
await act(async () => { result.current.handleReorder(12, []) })
await settle()
expect(toast.error).toHaveBeenCalledWith('trip.toast.reorderError')
expect(fixed.pushUndo).not.toHaveBeenCalled()
})
it('FE-TP-DAYPLAN-010: a day is renamed, the days reorder with an undo that steps aside once the days changed', async () => {
const { result } = renderEdits()
await act(async () => { await result.current.handleUpdateDayTitle(10, 'Coast') })
expect(actions.updateDayTitle).toHaveBeenCalledWith(42, 10, 'Coast')
actions.updateDayTitle.mockRejectedValueOnce(new Error('too.long')).mockRejectedValueOnce('nope')
await act(async () => { await result.current.handleUpdateDayTitle(10, 'x') })
expect(toast.error).toHaveBeenLastCalledWith('too.long')
await act(async () => { await result.current.handleUpdateDayTitle(10, 'x') })
expect(toast.error).toHaveBeenLastCalledWith('common.unknownError')
await act(async () => { result.current.handleReorderDays([12, 10, 11]) })
await settle()
expect(actions.reorderDays).toHaveBeenCalledWith(42, [12, 10, 11])
expect(fixed.pushUndo).toHaveBeenCalledWith('dayplan.reorderUndo', expect.any(Function))
const undo = lastUndo()
// A day deleted since then drops out of the old order.
useTripStore.setState({ days: [buildDay({ id: 10 }), buildDay({ id: 12 })] })
await act(async () => { await undo() })
expect(actions.reorderDays).toHaveBeenLastCalledWith(42, [10, 12])
// A day added since then makes the old order one the server could not take.
useTripStore.setState({ days: [buildDay({ id: 10 }), buildDay({ id: 11 }), buildDay({ id: 12 }), buildDay({ id: 13 })] })
actions.reorderDays.mockClear()
await act(async () => { await undo() })
expect(actions.reorderDays).not.toHaveBeenCalled()
actions.reorderDays.mockRejectedValueOnce(new Error('locked')).mockRejectedValueOnce('nope')
await act(async () => { result.current.handleReorderDays([10, 11, 12]) })
await settle()
expect(toast.error).toHaveBeenLastCalledWith('locked')
await act(async () => { result.current.handleReorderDays([10, 11, 12]) })
await settle()
expect(toast.error).toHaveBeenLastCalledWith('dayplan.reorderError')
})
})
@@ -0,0 +1,187 @@
import { useCallback } from 'react'
import { reanchorAfterRemove, reanchorByStopOrder } from '../../components/Roadtrip/roadtripModel'
import type { useRouteCalculation } from '../../hooks/useRouteCalculation'
import type { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import type { Accommodation, Place } from '../../types'
import { timedSlot } from '../../utils/dayMerge'
import type { PlannerBase, PlannerHistory } from './plannerTypes'
import type { PlaceEdits } from './usePlaceEdits'
import type { RoadtripFeed } from './useRoadtripFeed'
interface DayPlanEditsOptions
extends Pick<PlannerBase, 'tripId' | 'tripActions' | 'toast' | 't'>,
Pick<RoadtripFeed, 'roadtripVias' | 'roadtripStopsOf' | 'viasAfterInsert'>,
Pick<PlaceEdits, 'isTourPlace'>,
Pick<ReturnType<typeof useTourPlaceIds>, 'reloadTourPlaceIds'>,
Pick<PlannerHistory, 'pushUndo'> {
selectedDayId: TripStoreState['selectedDayId']
/** The day lists as stored, so a slot is counted among every stop of the day. */
storedAssignments: TripStoreState['assignments']
/** The places the planner lists, without the stops the day lists leave out. */
places: Place[]
tripAccommodations: Accommodation[]
updateRouteForDay: ReturnType<typeof useRouteCalculation>['updateRouteForDay']
}
/**
* Edits to the day plan, each with its undo where one makes sense: putting a place on
* a day, moving a stop to another day, taking one off, reordering a day, renaming a
* day and reordering the days. Every edit that shifts the stops of a drawn day carries
* that day's vias along, so the drive keeps the road the traveller chose.
*
* Nothing here runs an effect, so where useTripPlanner calls it changes nothing.
*/
export function useDayPlanEdits(options: DayPlanEditsOptions) {
const {
tripId, tripActions, toast, t, selectedDayId, storedAssignments, places, tripAccommodations,
isTourPlace, reloadTourPlaceIds, pushUndo, updateRouteForDay, roadtripVias, roadtripStopsOf, viasAfterInsert,
} = options
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return false }
if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false
const place = places.find(p => p.id === placeId)
// A place with a start of its own is drawn by it, so it is stored there too, the
// way a stop moved over from another day is. Without one it goes where it was put.
const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position
const plan = viasAfterInsert(target, slot, place)
let assignment: Awaited<ReturnType<typeof tripActions.assignPlaceToDay>>
try {
assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return false
}
if (isTourPlace(placeId)) await reloadTourPlaceIds()
toast.success(t('trip.toast.assignedToDay'))
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
if (isTourPlace(placeId)) await reloadTourPlaceIds()
}, [capturedTarget], [placeId])
}
if (plan) {
try { await roadtripVias.reanchor(target, plan) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
updateRouteForDay(target)
return true
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
/**
* Moves a stop from the day list onto another day, at a row of that day or at its
* end without one.
*
* It can land in the middle of a day whose road has been drawn, on the row it was
* dropped on or among the stops its start falls between. The vias of that day count
* stops, so every one behind the new stop would shape the leg before the one it was
* drawn on. They are moved the way a place added to the day moves them. Rejects when
* a write fails, so the list can say so and leave its undo out.
*/
const handleMoveToDay = useCallback(async (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => {
const place = (storedAssignments[String(fromDayId)] ?? []).find(a => a.id === assignmentId)?.place
const plan = viasAfterInsert(toDayId, position, place)
await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, position)
if (plan) await roadtripVias.reanchor(toDayId, plan)
}, [tripId, tripActions, storedAssignments, roadtripVias, viasAfterInsert])
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 removedTour = capturedPlaceId != null && isTourPlace(capturedPlaceId)
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 (removedTour) await reloadTourPlaceIds()
if (plan) await roadtripVias.reanchor(dayId, plan)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
if (removedTour) await reloadTourPlaceIds()
}, [capturedDayId], [capturedPlaceId])
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace])
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || []
const prevIds = assignmentsBefore
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))]
// 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 visible = new Set(orderedIds)
let nextVisible = 0
const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id)
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id))
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
try {
tripActions.reorderAssignments(tripId, dayId, completeOrder)
.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 () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}, [capturedDayId], placeIdsBefore)
})
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
updateRouteForDay(dayId)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, toast, pushUndo, updateRouteForDay, t, roadtripVias, roadtripStopsOf])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripActions.updateDayTitle(tripId, dayId, title) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast])
const handleReorderDays = useCallback((orderedIds: number[]) => {
const prevIds = (useTripStore.getState().days || [])
.slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
tripActions.reorderDays(tripId, orderedIds)
.then(() => {
pushUndo(t('dayplan.reorderUndo'), async () => {
// A day deleted since then drops out of the old order. When the list no
// longer matches the days there are (one was added), the old order is
// not one the server could take, so the undo steps aside.
const live = new Set(useTripStore.getState().days.map(d => d.id))
const restorable = prevIds.filter(id => live.has(id))
if (restorable.length !== live.size) return
await tripActions.reorderDays(tripId, restorable)
})
})
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
}, [tripId, toast, pushUndo])
return {
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle, handleReorderDays,
}
}
export type DayPlanEdits = ReturnType<typeof useDayPlanEdits>
+11 -149
View File
@@ -10,7 +10,7 @@ import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain,
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, healthApi, mapsApi, placesApi } from '../../api/client'
import { TRANSPORT_TYPES, timedSlot } from '../../utils/dayMerge'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
import { accommodationRepo } from '../../repo/accommodationRepo'
@@ -25,10 +25,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDayClear } from './useDayClear'
import {
reanchorAfterRemove,
reanchorByStopOrder,
isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
@@ -60,14 +57,14 @@ import { useRoadtripAlternatives } from './useRoadtripAlternatives'
import { useRoadtripDriveShaping } from './useRoadtripDriveShaping'
import { usePlaceEdits } from './usePlaceEdits'
import { usePlaceFormOpeners } from './usePlaceFormOpeners'
import { useDayPlanEdits } from './useDayPlanEdits'
import { useIsPhone } from '../../mobile/useIsPhone'
/**
* Trip planner page logic, the big one. Owns the trip store wiring, addon
* gating, accommodations/members loading, the tab + resizable-panel + selection
* state, every place/assignment/reservation/transport CRUD handler (with undo)
* and the splash gate. TripPlannerPage stays a wiring container that lays out
* the day/map/places panes and modals.
* state, the reservation and transport saves and the splash gate. TripPlannerPage
* stays a wiring container that lays out the day/map/places panes and modals.
* Behaviour is identical to the previous in-component logic.
*
* Self-contained parts live in the use* sub-hooks beside this file. Each one is
@@ -564,147 +561,12 @@ export function useTripPlanner() {
roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, isTourPlace,
})
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return false }
if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false
const place = places.find(p => p.id === placeId)
// A place with a start of its own is drawn by it, so it is stored there too, the
// way a stop moved over from another day is. Without one it goes where it was put.
const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position
const plan = viasAfterInsert(target, slot, place)
let assignment: Awaited<ReturnType<typeof tripActions.assignPlaceToDay>>
try {
assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return false
}
if (isTourPlace(placeId)) await reloadTourPlaceIds()
toast.success(t('trip.toast.assignedToDay'))
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
if (isTourPlace(placeId)) await reloadTourPlaceIds()
}, [capturedTarget], [placeId])
}
if (plan) {
try { await roadtripVias.reanchor(target, plan) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
updateRouteForDay(target)
return true
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert])
/**
* Moves a stop from the day list onto another day, at a row of that day or at its
* end without one.
*
* It can land in the middle of a day whose road has been drawn, on the row it was
* dropped on or among the stops its start falls between. The vias of that day count
* stops, so every one behind the new stop would shape the leg before the one it was
* drawn on. They are moved the way a place added to the day moves them. Rejects when
* a write fails, so the list can say so and leave its undo out.
*/
const handleMoveToDay = useCallback(async (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => {
const place = (storedAssignments[String(fromDayId)] ?? []).find(a => a.id === assignmentId)?.place
const plan = viasAfterInsert(toDayId, position, place)
await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, position)
if (plan) await roadtripVias.reanchor(toDayId, plan)
}, [tripId, tripActions, storedAssignments, roadtripVias, viasAfterInsert])
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 removedTour = capturedPlaceId != null && isTourPlace(capturedPlaceId)
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 (removedTour) await reloadTourPlaceIds()
if (plan) await roadtripVias.reanchor(dayId, plan)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
if (removedTour) await reloadTourPlaceIds()
}, [capturedDayId], [capturedPlaceId])
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace])
const handleReorder = useCallback((dayId: number, orderedIds: number[]) => {
const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || []
const prevIds = assignmentsBefore
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))]
// 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 visible = new Set(orderedIds)
let nextVisible = 0
const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id)
const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id)
const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id))
const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter)
try {
tripActions.reorderAssignments(tripId, dayId, completeOrder)
.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 () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}, [capturedDayId], placeIdsBefore)
})
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
updateRouteForDay(dayId)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, toast, pushUndo, updateRouteForDay, t, roadtripVias, roadtripStopsOf])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripActions.updateDayTitle(tripId, dayId, title) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast])
const handleReorderDays = useCallback((orderedIds: number[]) => {
const prevIds = (useTripStore.getState().days || [])
.slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
tripActions.reorderDays(tripId, orderedIds)
.then(() => {
pushUndo(t('dayplan.reorderUndo'), async () => {
// A day deleted since then drops out of the old order. When the list no
// longer matches the days there are (one was added), the old order is
// not one the server could take, so the undo steps aside.
const live = new Set(useTripStore.getState().days.map(d => d.id))
const restorable = prevIds.filter(id => live.has(id))
if (restorable.length !== live.size) return
await tripActions.reorderDays(tripId, restorable)
})
})
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
}, [tripId, toast, pushUndo])
const {
handleReorder, handleReorderDays, handleUpdateDayTitle, handleAssignToDay, handleMoveToDay, handleRemoveAssignment,
} = useDayPlanEdits({
tripId, tripActions, toast, t, places, storedAssignments, tripAccommodations, selectedDayId,
pushUndo, reloadTourPlaceIds, updateRouteForDay, isTourPlace, roadtripVias, viasAfterInsert, roadtripStopsOf,
})
const { handleAddDay, dayAdd } = useDayAdd({
tripId, trip, days, canEditDays: can('day_edit', trip), t, locale, toast,