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.
This commit is contained in:
Maurice
2026-09-13 01:13:33 +02:00
parent f0e1da2d81
commit 5e0245046d
5 changed files with 79 additions and 5 deletions
@@ -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
@@ -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 =>
@@ -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
+41
View File
@@ -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)])
})
})
+27
View File
@@ -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 })
}