diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 40e15538e..f215b4131 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -1478,4 +1478,24 @@ describe('PlaceInspector', () => { expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press'); }); -}); + + it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => { + // Taking it off the day would leave the booking behind with nothing on the drive and + // no way back short of saving it again; offering to add it would put a second copy + // of the same hotel beside it. The night is removed where it is made: the day's + // overnight block, or turning it back into a pause in road trip mode. + const onRemoveAssignment = vi.fn(); + const booked = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 }]; + render( + + ); + + expect(screen.queryByRole('button', { name: /remove from day/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /add to day/i })).toBeNull(); + }); +}) diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 0694657cc..9c1138ee6 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -321,6 +321,8 @@ export default function PlaceInspector({ ? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null) ?? dayAssignments.find(a => a.place?.id === place.id)) : null + /** This stop belongs to a booked night rather than to the traveller. */ + const bookedNight = assignmentInDay?.accommodation_id != null // The weekday lines are display text; the ring is computed from the structured // periods next to them, in the place's own timezone. open_now stays the fallback. @@ -509,8 +511,14 @@ export default function PlaceInspector({ {mode === 'collection' && collectionStatus && onSetStatus && ( )} - {/* Trip mode — day assignment */} - {mode === 'trip' && !!selectedDayId && ( + {/* Trip mode — day assignment. + A stop a lodging booking put there is not offered either way. Taking it off + the day would leave the booking behind with nothing on the drive and no way + back short of saving it again, and putting a second one beside it is the + duplicate the stop exists to prevent. The booking is removed where it is + made: in the day's overnight block, or by turning the night back into a + pause in road trip mode. */} + {mode === 'trip' && !!selectedDayId && !bookedNight && ( assignmentInDay ? ( onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={} label={{t('inspector.removeFromDay')}} /> diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 84b00ab7d..ac40c095d 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4782,6 +4782,45 @@ function runMigrations(db: Database.Database): void { } if (stays.length > 0) console.log(`[DB] Put ${stays.length} booked night(s) on their check-in day`); }, + + /* + * The same sweep once more, for the nights the first one could not have seen. + * + * A migration runs while the old container is still answering: a booking written + * in those seconds is written by code that knows nothing about the day stop, and + * lands behind the sweep that would have given it one. One did, on the test + * instance, out of ten. There is nothing to be done about that window, but there + * is something to be done about what falls into it, and a booking that never got + * its stop is invisible to the drive with no way back short of saving it again. + * + * Safe to repeat: the same NOT EXISTS decides it, so a stay that already has its + * stop is passed over, and one whose place the traveller planned by hand keeps + * that row unclaimed. Every future release can carry the same step for the same + * reason. + */ + () => { + const stays = db.prepare(` + SELECT a.id, a.place_id, a.start_day_id + FROM day_accommodations a + WHERE a.place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM day_assignments da + WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id + ) + ORDER BY a.id + `).all() as Array<{ id: number; place_id: number; start_day_id: number }>; + + const insert = db.prepare( + `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) + VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)` + ); + const stamp = db.prepare("UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')"); + for (const stay of stays) { + insert.run(stay.start_day_id, stay.place_id, stay.start_day_id, stay.id); + stamp.run(stay.place_id); + } + if (stays.length > 0) console.log(`[DB] Caught up ${stays.length} booked night(s) missed during the upgrade`); + }, ]; if (currentVersion < migrations.length) {