fix(client): give the desktop halves the same fixes their mobile twins got

Several of the fixes in this branch landed on a mobile screen while the
desktop component with the identical bug sat in another package and was left
alone, which leaves the two disagreeing about the same handler.

So: the appearance tab flushes its debounced write on unmount, the journey
share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted
install), the admin collab toggle rolls back from a scoped snapshot instead
of a stale one, the reservation modal parses metadata through the guarded
helper rather than a bare JSON.parse that takes the route down, and the
notification toggle surfaces its failures and stops racing concurrent
toggles.

The Journey components reached the memories provider-status endpoint with a
raw fetch(), past every apiClient interceptor; they go through a small
memoriesApi now. And the server half of the OSM menu_url finding: the client
renders it through safeHttpUrl, so place-enrichment should not be handing a
javascript: value out in the first place.

The share section hides itself when the server says the link is not this
user's to manage, rather than offering a create button that 403s.
This commit is contained in:
Maurice
2026-08-23 10:38:23 +02:00
committed by Maurice
parent e72338bbbc
commit 3e0bfbbb29
22 changed files with 435 additions and 76 deletions
@@ -1423,4 +1423,27 @@ describe('ReservationModal', () => {
// Hotel place picker still shows its placeholder — no crash on the missing record.
expect(screen.getByText('—')).toBeInTheDocument();
});
// A legacy or corrupted metadata column used to throw straight out of the open
// effect and take the whole planner route down.
it('FE-PLANNER-RESMODAL-087: unparseable metadata opens the modal with empty check-in times', () => {
const res = buildReservation({ id: 17, title: 'Grand Hotel', type: 'hotel' });
(res as unknown as { metadata: string }).metadata = '{not json';
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
expect(screen.getByDisplayValue('Grand Hotel')).toBeInTheDocument();
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
expect(times.map(i => i.value)).toEqual(['', '', '']);
});
it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => {
const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' });
(res as unknown as { metadata: string }).metadata =
JSON.stringify(JSON.stringify({ check_in_time: '16:00', check_out_time: '10:30' }));
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
expect(times[0].value).toBe('16:00');
expect(times[2].value).toBe('10:30');
});
});
@@ -12,6 +12,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
@@ -133,7 +134,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
const meta = parseReservationMetadata(reservation)
const rawEnd = reservation.reservation_end_time || ''
let endDate = ''
let endTime = rawEnd