From 5e0245046d5f5702ca04a96cc3a311d32198d9fb Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 01:13:33 +0200 Subject: [PATCH] feat(planner): show the stop a booked night writes without a reload The server announces the day stop over the socket like any other assignment, but the socket deliberately skips the session that sent the request, so the one person who could not see their new stop was the one who had just booked the night. That looks exactly like the bug it fixes. The stay writes hand their answer to one applier, which folds the stop into the store through the same path the socket uses, so the IndexedDB write-through happens either way and the dedupe by assignment id comes for free. Three surfaces call it: the day-detail picker, its edit branch and the phone sheet. The road-trip flow does not, because it writes its own assignment before the stay and there is never a mirrored stop to apply. --- .../src/components/Planner/DayDetailPanel.tsx | 5 ++- client/src/components/Planner/useDayDetail.ts | 5 ++- .../trip/sheets/MAccommodationSheet.tsx | 6 ++- client/src/store/stayStops.test.ts | 41 +++++++++++++++++++ client/src/store/stayStops.ts | 27 ++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 client/src/store/stayStops.test.ts create mode 100644 client/src/store/stayStops.ts diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index a73f35ad5..b1c4e0f7f 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -10,6 +10,7 @@ import { usePluginStore } from '../../store/pluginStore' import PluginFrame from '../Plugins/PluginFrame' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import { applyStayStops } from '../../store/stayStops' import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' import { useSettingsStore } from '../../store/settingsStore' @@ -522,7 +523,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD return } try { - await accommodationsApi.update(tripId, accommodation.id, { + applyStayStops(await accommodationsApi.update(tripId, accommodation.id, { place_id: hotelForm.place_id, start_day_id: hotelDayRange.start, end_day_id: hotelDayRange.end, @@ -530,7 +531,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD check_in_end: hotelForm.check_in_end || null, check_out: hotelForm.check_out || null, confirmation: hotelForm.confirmation || null, - }) + })) setShowHotelPicker(false) setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) // Reload diff --git a/client/src/components/Planner/useDayDetail.ts b/client/src/components/Planner/useDayDetail.ts index b4e5ad94a..ebb1c9a52 100644 --- a/client/src/components/Planner/useDayDetail.ts +++ b/client/src/components/Planner/useDayDetail.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { weatherApi, accommodationsApi } from '../../api/client' import { isDayInAccommodationRange } from '../../utils/dayOrder' +import { applyStayStops } from '../../store/stayStops' /** Day-detail data + accommodation logic: weather load, accommodations list, * hotel picker form state and create/update/delete handlers. */ @@ -62,6 +63,7 @@ export function useDayDetail(day: any, days: any, tripId: any, lat: any, lng: an check_out: hotelForm.check_out || null, confirmation: hotelForm.confirmation || null, }) + applyStayStops(data) const newAcc = data.accommodation const updated = [...accommodations, newAcc] setAccommodations(updated) @@ -79,6 +81,7 @@ export function useDayDetail(day: any, days: any, tripId: any, lat: any, lng: an if (!accommodation) return try { const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null }) + applyStayStops(data) setAccommodation(data.accommodation) onAccommodationChange?.() } catch {} @@ -87,7 +90,7 @@ export function useDayDetail(day: any, days: any, tripId: any, lat: any, lng: an const handleRemoveAccommodation = async () => { if (!accommodation) return try { - await accommodationsApi.delete(tripId, accommodation.id) + applyStayStops(await accommodationsApi.delete(tripId, accommodation.id)) const updated = accommodations.filter(a => a.id !== accommodation.id) setAccommodations(updated) setDayAccommodations(updated.filter(a => diff --git a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx index 93fdbdec4..ed6b22b57 100644 --- a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx @@ -5,6 +5,7 @@ import MIconBtn from '../../../components/MIconBtn' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' import { accommodationsApi } from '../../../../api/client' +import { applyStayStops } from '../../../../store/stayStops' import { useTranslation } from '../../../../i18n' import { Eyebrow } from './MTripSheetUi' import type { MTripSheetsProps } from '../MTripShell' @@ -111,8 +112,9 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps // Only the write itself decides whether the save failed — a refresh that // trips afterwards must not be reported as a failed save. try { - if (editing) await accommodationsApi.update(tripId, editing.id, body) - else await accommodationsApi.create(tripId, body) + applyStayStops(editing + ? await accommodationsApi.update(tripId, editing.id, body) + : await accommodationsApi.create(tripId, body)) } catch { planner.toast.error(t('common.error')) return diff --git a/client/src/store/stayStops.test.ts b/client/src/store/stayStops.test.ts new file mode 100644 index 000000000..4d6c2ee16 --- /dev/null +++ b/client/src/store/stayStops.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useTripStore } from './tripStore' +import { applyStayStops } from './stayStops' +import type { Assignment } from '../types' + +vi.mock('../db/offlineDb', () => ({ + offlineDb: { days: { get: vi.fn(), put: vi.fn() }, places: { put: vi.fn(), delete: vi.fn() } }, + upsertAccommodations: vi.fn(), +})) + +const stop = (id: number, dayId: number): Assignment => + ({ id, day_id: dayId, place_id: 7, order_index: 0, notes: null, place: { id: 7, name: 'Hotel Adlon' } }) as unknown as Assignment + +beforeEach(() => { + useTripStore.setState({ assignments: {} }) +}) + +describe('applyStayStops', () => { + it('FE-STAY-STOPS-001 puts the stop a booking reported into the day it belongs to', () => { + // The socket skips the session that sent the request, so the answer is the only + // way the person who just booked the night learns about their own stop. + applyStayStops({ assignment: stop(77, 3) }) + expect(useTripStore.getState().assignments['3']).toEqual([stop(77, 3)]) + }) + + it('FE-STAY-STOPS-002 takes a stop back off the old day when the booking moved', () => { + useTripStore.setState({ assignments: { '3': [stop(77, 3)] } }) + applyStayStops({ assignment: stop(78, 4), removedAssignments: [{ id: 77, dayId: 3 }] }) + expect(useTripStore.getState().assignments['3']).toEqual([]) + expect(useTripStore.getState().assignments['4']).toEqual([stop(78, 4)]) + }) + + it('FE-STAY-STOPS-003 does nothing for a write that left the day plan alone', () => { + useTripStore.setState({ assignments: { '3': [stop(77, 3)] } }) + // Booking a night at a place that was already planned for that day, and every + // older server that answers without the field at all. + applyStayStops({ assignment: null, removedAssignments: [] }) + applyStayStops(undefined) + expect(useTripStore.getState().assignments['3']).toEqual([stop(77, 3)]) + }) +}) diff --git a/client/src/store/stayStops.ts b/client/src/store/stayStops.ts new file mode 100644 index 000000000..7f5ed2f6c --- /dev/null +++ b/client/src/store/stayStops.ts @@ -0,0 +1,27 @@ +import { useTripStore } from './tripStore' +import type { Assignment } from '../types' + +/** The day-plan half of what an accommodation write answers with. */ +export interface StayStopsResult { + assignment?: Assignment | null + removedAssignments?: { id: number; dayId: number }[] +} + +/** + * Fold the day stop a stay write reported back into the store. + * + * Booking a night also puts the place on its check-in day, because that stop is + * what the road trip routes and the map draws. The server announces it over the + * socket like any other assignment, but the socket deliberately skips the session + * that sent the request (X-Socket-Id), so without this the one person who cannot + * see their new stop is the one who just booked it. Goes through the same applier + * the socket uses, so the write-through to IndexedDB happens either way. + */ +export function applyStayStops(result: StayStopsResult | null | undefined): void { + if (!result) return + const { handleRemoteEvent } = useTripStore.getState() + for (const removed of result.removedAssignments ?? []) { + handleRemoteEvent({ type: 'assignment:deleted', assignmentId: removed.id, dayId: removed.dayId }) + } + if (result.assignment) handleRemoteEvent({ type: 'assignment:created', assignment: result.assignment }) +}