fix(accommodations): catch the nights the upgrade itself could not reach

A migration runs while the old container is still answering. A booking written in
those seconds comes from code that knows nothing about the day stop and lands
behind the sweep that would have given it one: on the test instance exactly one
of ten, and invisible to the drive ever after, with no way back short of saving
it again. The sweep runs a second time, which is safe because the same NOT EXISTS
decides it both times.

The other way into that state is closed too. "Remove from day" in the place panel
took the stop and left the booking, which is the very state this whole change
exists to remove — and it was the only control that could produce it after the
upgrade. A stop a booking wrote is no longer offered there, in either direction:
removing it strands the booking, adding one puts the same hotel on the day twice.
The night is removed where it is made, in the day's overnight block or by turning
it back into a pause in road trip mode.
This commit is contained in:
Maurice
2026-09-13 15:19:33 +02:00
parent a89ad06ba5
commit 62d4cdcc18
3 changed files with 70 additions and 3 deletions
@@ -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(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
assignments={{ '1': booked } as never}
onRemoveAssignment={onRemoveAssignment}
/>
);
expect(screen.queryByRole('button', { name: /remove from day/i })).toBeNull();
expect(screen.queryByRole('button', { name: /add to day/i })).toBeNull();
});
})
@@ -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 && (
<StatusBadge status={collectionStatus} onChange={onSetStatus} t={t} />
)}
{/* 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 ? (
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
+39
View File
@@ -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) {