diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx
index 68efc409a..40c3f770a 100644
--- a/client/src/pages/SharedTripPage.test.tsx
+++ b/client/src/pages/SharedTripPage.test.tsx
@@ -1353,6 +1353,39 @@ describe('SharedTripPage', () => {
});
});
+ describe('FE-PAGE-SHARED-037b: a booked night is not listed twice on a shared day', () => {
+ // Booking a night puts its hotel on the check-in day as a stop, because road trip
+ // mode drives to it. A shared link has no road trip view and already shows the
+ // booking as its own chip on the day, so that stop would be the same hotel again.
+ const days = [{ id: 41, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' }];
+ // The address is the tell: the list row prints it, the map marker's tooltip does not,
+ // so it says whether the row rendered rather than whether the place exists at all.
+ const place = (id: number, name: string, address: string) => ({
+ id, name, description: null, address, lat: 48.86, lng: 2.34, category_id: null,
+ price: null, place_time: null, end_time: null, duration_minutes: null, notes: null,
+ website: null, phone: null, image_url: null, transport_mode: null, category: null, tags: [],
+ });
+
+ it('leaves out the stop the booking wrote and keeps the one the traveller placed', async () => {
+ await open('stay-token', payload({
+ days,
+ assignments: {
+ 41: [
+ { id: 401, day_id: 41, order_index: 0, notes: null, accommodation_id: null, place: place(601, 'Musee Rodin', '77 Rue de Varenne') },
+ { id: 402, day_id: 41, order_index: 1, notes: null, accommodation_id: 7, place: place(602, 'Hotel Adlon', 'Unter den Linden 77') },
+ ],
+ },
+ accommodations: [{ id: 7, place_id: 602, start_day_id: 41, end_day_id: 41, place_name: 'Hotel Adlon' }],
+ }));
+
+ fireEvent.click(screen.getByText('Day One'));
+ // The stop the traveller placed is listed, address and all.
+ await waitFor(() => expect(screen.getByText('77 Rue de Varenne')).toBeInTheDocument());
+ // The booked night is not: it is already on the day as its own chip.
+ expect(screen.queryByText('Unter den Linden 77')).toBeNull();
+ });
+ });
+
// ── #2320: the richer read-only detail ─────────────────────────────────
describe('FE-PAGE-SHARED-038: a stop shows its address, description, both notes, duration and contact (#2320)', () => {
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx
index c9dadaf61..9e04a494f 100644
--- a/client/src/pages/SharedTripPage.tsx
+++ b/client/src/pages/SharedTripPage.tsx
@@ -465,7 +465,11 @@ export default function SharedTripPage() {
{/* Day Plan */}
{sortedDays.map((day: any, di: number) => {
- const da = assignments[String(day.id)] || [];
+ // Without the booked nights: the day already shows each of them as its
+ // own chip, and the stop a booking writes for the drive would be that
+ // same hotel a second time. There is no road trip view on a shared link,
+ // so the stop has nothing else to do here.
+ const da = (assignments[String(day.id)] || []).filter((a: any) => a.accommodation_id == null);
// A share can still carry an assignment for a deleted place. The timeline
// skips those rows, so the header must not count them either.
const dayPlaceCount = da.filter((a: any) => a.place).length;
diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts
index 409d74708..4e4f5d37e 100644
--- a/server/src/nest/accommodations/accommodations.service.ts
+++ b/server/src/nest/accommodations/accommodations.service.ts
@@ -255,11 +255,13 @@ export class AccommodationsService {
// Through AssignmentsService, so the stop lands at the end of the day with the
// order_index every other new assignment gets. Evening is where you arrive at a
// hotel, and the day planner has no drive to position it against anyway.
- const created = this.assignments.createAssignment(dayId, placeId);
- if (created) {
- this.db.run('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?', accommodationId, created.id);
- mirror.created = created;
- }
+ //
+ // The booking id goes in with the INSERT, not as an UPDATE afterwards: what this
+ // returns is the row the answer hands the client, and stamping the id on later
+ // would leave that copy without it. The day list has nothing else to tell the
+ // stop from a place the traveller added, so it would show the hotel a second
+ // time until the next reload.
+ mirror.created = this.assignments.createAssignment(dayId, placeId, null, { accommodationId });
return mirror;
}
diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts
index 0021283db..8170ff50a 100644
--- a/server/src/nest/assignments/assignments.service.ts
+++ b/server/src/nest/assignments/assignments.service.ts
@@ -124,14 +124,21 @@ export class AssignmentsService {
return !!this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
}
- createAssignment(dayId: string | number, placeId: unknown, notes?: string | null) {
+ /**
+ * @param opts.accommodationId The lodging booking this stop belongs to, when a
+ * booking is what put it there. Written by the INSERT rather than stamped on
+ * afterwards: the row this returns is what the answer hands the client, and a
+ * stop that reaches it without its booking id is one the day list cannot tell
+ * from a place the traveller added, so it draws the hotel a second time.
+ */
+ createAssignment(dayId: string | number, placeId: unknown, notes?: string | null, opts: { accommodationId?: number } = {}) {
const result = this.dbs.transaction(() => {
const maxOrder = this.dbs.get<{ max: number | null }>('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?', dayId)!;
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
return this.dbs.run(
- 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)',
- dayId, placeId, orderIndex, notes || null
+ 'INSERT INTO day_assignments (day_id, place_id, order_index, notes, accommodation_id) VALUES (?, ?, ?, ?, ?)',
+ dayId, placeId, orderIndex, notes || null, opts.accommodationId ?? null
);
});
diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts
index 800f2c80a..4e0b36fdf 100644
--- a/server/src/nest/common/rowShape.ts
+++ b/server/src/nest/common/rowShape.ts
@@ -21,6 +21,10 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[]
end_day: a.end_day === 1,
leg_transport_mode: a.leg_transport_mode ?? null,
incoming_leg_transport_mode: a.incoming_leg_transport_mode ?? null,
+ // Which booking put this stop here, if a booking did. The day list has nothing
+ // else to tell it from a place the traveller added, and it must not draw the
+ // hotel a second time under the overnight block that already names it.
+ accommodation_id: a.accommodation_id ?? null,
participants: participants || [],
created_at: a.created_at,
place: {
diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts
index 625720bca..e26398746 100644
--- a/server/src/nest/days/days.service.ts
+++ b/server/src/nest/days/days.service.ts
@@ -114,6 +114,9 @@ export class DaysService {
order_index: a.order_index,
end_day: a.end_day === 1,
notes: a.notes,
+ // Repeated here for the same reason stop_type is: the day list hides the stop
+ // a booking wrote, and this copy is one of the paths that feed it.
+ accommodation_id: a.accommodation_id ?? null,
created_at: a.created_at,
place: {
id: a.place_id,
diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts
index 9b36e992b..87d8851ef 100644
--- a/server/src/nest/share/share.service.ts
+++ b/server/src/nest/share/share.service.ts
@@ -316,6 +316,9 @@ export class ShareService {
if (!byDay[a.day_id]) byDay[a.day_id] = [];
byDay[a.day_id].push({
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
+ // The shared page shows the booking as its own chip on the day, so it needs
+ // to know which stop is that booking and leave it out of the list.
+ accommodation_id: a.accommodation_id ?? null,
place: {
id: a.place_id, name: a.place_name, description: a.place_description,
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
diff --git a/server/src/types.ts b/server/src/types.ts
index 6b566c5fa..fedda2ffc 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -120,6 +120,8 @@ export interface DayAssignment {
end_day?: number;
leg_transport_mode?: string | null;
incoming_leg_transport_mode?: string | null;
+ /** The lodging booking that put this stop on the day, when one did. */
+ accommodation_id?: number | null;
created_at?: string;
}
diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts
index c71818bf2..62cf19052 100644
--- a/server/tests/unit/nest/accommodations.service.test.ts
+++ b/server/tests/unit/nest/accommodations.service.test.ts
@@ -607,6 +607,11 @@ describe('the day stop a booking implies', () => {
expect(stops).toHaveLength(1);
expect(stops[0]).toMatchObject({ place_id: place.id, accommodation_id: accommodation.id });
expect(mirror.created).toMatchObject({ id: stops[0].id, day_id: day.id, place_id: place.id });
+ // On the answer, not only in the table. This copy is what the client that booked
+ // the night puts in its store, and accommodation_id is the only thing telling the
+ // day list this row is the booking it already shows in the header. Without it the
+ // hotel appears a second time among the day's places until the next reload.
+ expect(mirror.created.accommodation_id).toBe(accommodation.id);
});
it('ACC-022 the stop lands at the end of the day, where you arrive at a hotel', () => {