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) {