feat: optimize routes around accommodation, confirm note deletions (#1123)

Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.
This commit is contained in:
Maurice
2026-06-07 12:52:06 +02:00
committed by GitHub
parent 093e069ccc
commit 49b3af8b0d
72 changed files with 504 additions and 26 deletions
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/notes', () =>
@@ -193,8 +193,11 @@ describe('CollabNotes', () => {
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Remove Me');
const deleteBtn = screen.getByTitle('Delete');
await user.click(deleteBtn);
await user.click(screen.getByTitle('Delete'));
// Deleting now asks for confirmation first — the note stays until confirmed.
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
});
+15 -2
View File
@@ -10,6 +10,7 @@ import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import type { User } from '../../types'
import type { CollabNote } from './CollabNotes.types'
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
@@ -44,6 +45,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null)
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
// Empty categories (no notes yet) stored in localStorage
const [emptyCategories, setEmptyCategories] = useState(() => {
@@ -231,6 +233,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
}
}
@@ -319,7 +322,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
function CollabNotesGrid(S: NotesState) {
const {
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S
return (
@@ -352,7 +355,7 @@ function CollabNotesGrid(S: NotesState) {
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onDelete={setPendingDeleteNoteId}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
@@ -470,6 +473,7 @@ export default function CollabNotes(props: CollabNotesProps) {
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S
if (loading) return <CollabNotesLoading {...S} />
@@ -527,6 +531,15 @@ export default function CollabNotes(props: CollabNotesProps) {
t={t}
/>
)}
{/* Confirm: delete a collab note — guards against accidental deletion */}
<ConfirmDialog
isOpen={pendingDeleteNoteId !== null}
onClose={() => setPendingDeleteNoteId(null)}
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
title={t('collab.notes.confirmDeleteTitle')}
message={t('collab.notes.confirmDeleteBody')}
/>
</div>
)
}
@@ -16,7 +16,7 @@ interface NoteCardProps {
currentUser: User
canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
onDelete: (noteId: number) => void
onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
@@ -161,6 +161,62 @@ describe('optimizeRoute', () => {
expect(result[1]).toEqual(c)
expect(result[2]).toEqual(b)
})
it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
const c = { lat: 5, lng: 1 }
// From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1)
const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } })
expect(result).toEqual([b, c, a])
})
it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 8, lng: 1 }
// Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last.
const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } })
expect(result).toEqual([a, b, c])
})
it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
// Without anchors two stops are returned unchanged; the start anchor orders them by proximity.
const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } })
expect(result).toEqual([b, a])
})
it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => {
const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf
const stops = [
{ id: 1, lat: 48.8565, lng: 2.3324 },
{ id: 2, lat: 48.8813, lng: 2.3151 },
{ id: 3, lat: 48.8796, lng: 2.308 },
{ id: 4, lat: 48.8723, lng: 2.2926 },
{ id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel
]
const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
Math.hypot(a.lat - b.lat, a.lng - b.lng)
const loop = (order: typeof stops) =>
d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel)
const result = optimizeRoute(stops, { start: hotel, end: hotel })
// The optimized loop is no longer than the original order…
expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9)
// …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel.
expect([result[0].id, result[result.length - 1].id]).toContain(5)
})
it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 9, lng: 1 }
// a is nearest the end anchor, so the route must finish at a rather than start there.
const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } })
expect(result[result.length - 1]).toEqual(a)
})
})
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
+76 -13
View File
@@ -1,4 +1,4 @@
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -77,35 +77,98 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
return `https://www.google.com/maps/dir/${stops}`
}
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 2) return places
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
function sqDist(a: Waypoint, b: Waypoint): number {
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
}
// Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor.
// With start === end this is a closed loop back to the anchor (a day out from and back to the hotel).
function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number {
if (order.length === 0) return 0
let total = 0
if (start) total += Math.sqrt(sqDist(start, order[0]))
for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1]))
if (end) total += Math.sqrt(sqDist(order[order.length - 1], end))
return total
}
// Greedy nearest-neighbor ordering, seeded at the start anchor when there is one.
function nearestNeighborOrder<T extends Waypoint>(valid: T[], start?: Waypoint): T[] {
const visited = new Set<number>()
const result: T[] = []
let current = valid[0]
visited.add(0)
result.push(current)
let current: Waypoint
if (start) {
current = start
} else {
current = valid[0]
visited.add(0)
result.push(valid[0])
}
while (result.length < valid.length) {
let nearestIdx = -1
let minDist = Infinity
for (let i = 0; i < valid.length; i++) {
if (visited.has(i)) continue
const d = Math.sqrt(
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
)
const d = sqDist(valid[i], current)
if (d < minDist) { minDist = d; nearestIdx = i }
}
if (nearestIdx === -1) break
visited.add(nearestIdx)
current = valid[nearestIdx]
result.push(current)
result.push(valid[nearestIdx])
}
return result
}
// 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings
// a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip
// (start === end) is untangled into a clean loop rather than an open path.
function twoOptImprove<T extends Waypoint>(order: T[], start?: Waypoint, end?: Waypoint): T[] {
if (order.length < 3) return order
let best = order
let bestLen = tourLength(best, start, end)
let improved = true
while (improved) {
improved = false
for (let i = 0; i < best.length - 1; i++) {
for (let j = i + 1; j < best.length; j++) {
const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1))
const len = tourLength(candidate, start, end)
if (len < bestLen - 1e-12) {
best = candidate
bestLen = len
improved = true
}
}
}
}
return best
}
/**
* Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order,
* then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's
* ends — start === end makes it a loop out from and back to the hotel; a transfer day runs start → end.
*/
export function optimizeRoute<T extends Waypoint>(places: T[], anchors: RouteAnchors = {}): T[] {
const { start, end } = anchors
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 1) return places
// Two unanchored stops have no meaningful order to optimize; anchors can still flip them.
if (valid.length === 2 && !start && !end) return places
const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end)
// A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel —
// that reads naturally as "leave the hotel, head to the closest place, …, come back".
if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) {
if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse()
}
return order
}
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
export async function calculateSegments(
waypoints: Waypoint[],
@@ -982,7 +982,7 @@ describe('DayPlanSidebar', () => {
}
})
it('FE-PLANNER-DAYPLAN-065: note card delete button calls deleteNote', async () => {
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
@@ -992,6 +992,11 @@ describe('DayPlanSidebar', () => {
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 1) {
await user.click(noteEditBtns[1] as HTMLElement)
// Clicking delete opens a confirmation dialog rather than deleting immediately.
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
expect(screen.getByText('Delete note?')).toBeInTheDocument()
// Confirming triggers the actual delete.
await user.click(screen.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
}
})
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLi
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -17,7 +18,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
@@ -451,6 +452,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
_openEditNote(dayId, note)
}
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
e?.stopPropagation()
await _deleteNote(dayId, noteId)
@@ -703,8 +708,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Optimize only unlocked assignments (work on assignments, not places)
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
const day = days.find(d => d.id === selectedDayId)
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
? getAccommodationAnchors(day, days, accommodations)
: {}
const optimizedAssignments = unlockedWithCoords.length >= 2
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
: unlockedWithCoords
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
@@ -717,7 +728,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
await onReorder(selectedDayId, result.map(a => a.id))
toast.success(t('dayplan.toast.routeOptimized'))
const usedHotel = !!(anchors.start || anchors.end)
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
const capturedDayId = selectedDayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
@@ -851,6 +863,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
cancelNote,
saveNote,
deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
moveNote,
expandedDays,
setExpandedDays,
@@ -993,6 +1007,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
cancelNote,
saveNote,
deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
moveNote,
expandedDays,
setExpandedDays,
@@ -1908,7 +1924,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
]) : undefined}
onMouseEnter={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
@@ -1950,7 +1966,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
@@ -2093,6 +2109,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
t={t}
/>
{/* Confirm: delete a day note — guards against accidental taps on touch devices */}
<ConfirmDialog
isOpen={!!pendingDeleteNote}
onClose={() => setPendingDeleteNote(null)}
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
title={t('dayplan.confirmDeleteNoteTitle')}
message={t('dayplan.confirmDeleteNoteBody')}
/>
{/* Transport-Detail-Modal */}
<DayPlanSidebarTransportDetailModal
transportDetail={transportDetail}
@@ -291,6 +291,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
))}
</div>
</div>
{/* Optimize route from accommodation */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.optimizeFromAccommodation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('optimize_from_accommodation', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.optimize_from_accommodation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.optimize_from_accommodation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.optimizeFromAccommodationHint')}</p>
</div>
</Section>
)
}
+1
View File
@@ -32,6 +32,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
optimize_from_accommodation: true,
map_provider: 'leaflet',
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
+7
View File
@@ -113,6 +113,7 @@ export interface Settings {
show_place_description: boolean
blur_booking_codes?: boolean
map_booking_labels?: boolean
optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl'
mapbox_access_token?: string
mapbox_style?: string
@@ -162,6 +163,12 @@ export interface Waypoint {
lng: number
}
// Optional fixed start/end points for route optimization (e.g. the day's accommodation).
export interface RouteAnchors {
start?: Waypoint
end?: Waypoint
}
// User with optional OIDC fields
export interface UserWithOidc extends User {
oidc_issuer?: string | null
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import type { Day, Accommodation } from '../types'
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
const days = [
{ id: 10, day_number: 1 },
{ id: 20, day_number: 2 },
{ id: 30, day_number: 3 },
] as unknown as Day[]
const hotel = (over: Partial<Accommodation>): Accommodation =>
({ place_lat: 48.1, place_lng: 11.5, start_day_id: 10, end_day_id: 30, ...over }) as Accommodation
describe('getDayOrder', () => {
it('prefers day_number when present', () => {
expect(getDayOrder(days[1], days)).toBe(2)
})
it('falls back to array index when day_number is missing', () => {
const noNumber = [{ id: 5 }, { id: 6 }] as unknown as Day[]
expect(getDayOrder(noNumber[1], noNumber)).toBe(1)
})
})
describe('isDayInAccommodationRange', () => {
it('is inclusive of both the check-in and check-out day', () => {
expect(isDayInAccommodationRange(days[0], 10, 30, days)).toBe(true) // check-in morning
expect(isDayInAccommodationRange(days[1], 10, 30, days)).toBe(true) // mid-stay
expect(isDayInAccommodationRange(days[2], 10, 30, days)).toBe(true) // check-out day
})
it('excludes days outside the stay', () => {
expect(isDayInAccommodationRange(days[0], 20, 30, days)).toBe(false)
})
})
describe('getAccommodationAnchors', () => {
it('returns no anchors when the day has no accommodation', () => {
expect(getAccommodationAnchors(days[1], days, [])).toEqual({})
})
it('anchors both ends to the same hotel on a mid-stay day (round trip)', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: 48.1, place_lng: 11.5 })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
start: { lat: 48.1, lng: 11.5 },
end: { lat: 48.1, lng: 11.5 },
})
})
it('loops a single hotel on its check-out day (home base for the day)', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 2 })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({ start: { lat: 1, lng: 2 }, end: { lat: 1, lng: 2 } })
})
it('loops a single hotel on its check-in day (home base for the day)', () => {
const accs = [hotel({ start_day_id: 20, end_day_id: 30, place_lat: 3, place_lng: 4 })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } })
})
it('uses the checked-out hotel as start and the checked-in hotel as end on a transfer day', () => {
const accs = [
hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 }), // checkout today
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
start: { lat: 1, lng: 1 },
end: { lat: 9, lng: 9 },
})
})
it('ignores accommodations that have no coordinates', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
})
})
+27 -1
View File
@@ -1,8 +1,34 @@
import type { Day } from '../types'
import type { Day, Accommodation, RouteAnchors } from '../types'
export const getDayOrder = (day: Day, days: Day[]): number =>
day.day_number ?? days.indexOf(day)
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
// and into another — instead runs from the morning hotel to the evening one.
export const getAccommodationAnchors = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): RouteAnchors => {
const located = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
)
if (located.length === 0) return {}
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
if (checkOut && checkIn && checkOut !== checkIn) {
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
}
const hotel = toAnchor(located[0])
return { start: hotel, end: hotel }
}
export const isDayInAccommodationRange = (
day: Day,
startDayId: number,