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 }) +}