feat(assistant): booking and stay cards, and a read of the bookings with their details

This commit is contained in:
Maurice
2026-10-22 15:50:50 +02:00
parent b52990bf27
commit b4e188fbfc
67 changed files with 6336 additions and 103 deletions
+65
View File
@@ -0,0 +1,65 @@
import { renderHook } from '@testing-library/react';
import type { Reservation } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { useBookingLink } from './useBookingLink';
/**
* FE-HOOK-BOOKLINK-001 to FE-HOOK-BOOKLINK-004
*
* A booking the chat points at: taken once the trip's bookings are there,
* then dropped from the URL, an unknown one dropped without a word.
*/
const booking = (id: number, tripId = 5) => ({ id, trip_id: tripId, type: 'flight' }) as Reservation;
function setup(query: string, reservations: Reservation[], loaded: boolean) {
const searchParams = new URLSearchParams(query);
const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams));
const take = vi.fn();
const hook = renderHook(
({ list, done }) =>
useBookingLink({
searchParams,
setSearchParams: setSearchParams as never,
tripId: 5,
reservations: list,
loaded: done,
take,
}),
{ initialProps: { list: reservations, done: loaded } }
);
return { searchParams, setSearchParams, take, hook };
}
describe('useBookingLink', () => {
it('FE-HOOK-BOOKLINK-001: opens a booking of this trip once and drops the link', () => {
const { searchParams, take, hook } = setup('tab=transports&booking=7', [booking(7)], false);
expect(take).toHaveBeenCalledTimes(1);
expect(take).toHaveBeenCalledWith(booking(7), false);
expect(searchParams.has('booking')).toBe(false);
expect(searchParams.get('tab')).toBe('transports');
hook.rerender({ list: [booking(7)], done: true });
expect(take).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-BOOKLINK-002: hands on map=1 and drops it with the link', () => {
const { searchParams, take } = setup('booking=7&map=1', [booking(7)], true);
expect(take).toHaveBeenCalledWith(booking(7), true);
expect(searchParams.has('map')).toBe(false);
});
it("FE-HOOK-BOOKLINK-003: waits for the trip's bookings, then drops an id it does not hold", () => {
const { searchParams, take, hook } = setup('booking=9', [booking(9, 4)], false);
expect(searchParams.get('booking')).toBe('9');
hook.rerender({ list: [booking(7)], done: true });
expect(take).not.toHaveBeenCalled();
expect(searchParams.has('booking')).toBe(false);
});
it('FE-HOOK-BOOKLINK-004: does nothing without a link', () => {
const { setSearchParams, take } = setup('tab=plan', [booking(7)], true);
expect(setSearchParams).not.toHaveBeenCalled();
expect(take).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { Reservation } from '@trek/shared';
import { useEffect, useRef } from 'react';
import type { SetURLSearchParams } from 'react-router';
interface BookingLinkOptions {
searchParams: URLSearchParams;
setSearchParams: SetURLSearchParams;
tripId: number;
reservations: readonly Reservation[];
/** True once this trip's bookings have loaded, so an id it does not hold can be dropped. */
loaded: boolean;
/** Opens the booking: its detail, or with `onMap` its route on the plan. */
take: (booking: Reservation, onMap: boolean) => void;
}
/**
* A booking the chat points at (`?booking=<id>`, `&map=1` for its route on
* the plan), for the desktop planner and the phone alike: once the trip's
* bookings are there it is handed to `take`, then the link leaves the URL. It
* is taken once, so a render before the URL has dropped it does not open it
* twice; an id the trip does not hold is dropped without a word.
*/
export function useBookingLink({
searchParams,
setSearchParams,
tripId,
reservations,
loaded,
take,
}: BookingLinkOptions): void {
const requested = searchParams.get('booking');
const onMap = searchParams.get('map') === '1';
const id = requested === null ? null : Number(requested);
const booking = id === null ? undefined : reservations.find((r) => r.id === id && r.trip_id === tripId);
const settled = !!booking || loaded;
const taken = useRef<string | null>(null);
useEffect(() => {
if (requested === null) {
taken.current = null;
return;
}
const link = `${requested}:${onMap}`;
if (!settled || taken.current === link) return;
taken.current = link;
if (booking) take(booking, onMap);
setSearchParams(
(params) => {
params.delete('booking');
params.delete('map');
return params;
},
{ replace: true }
);
}, [requested, settled, booking, onMap, take, setSearchParams]);
}