mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user