mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user