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
@@ -0,0 +1,163 @@
import type {
AssistantMessage,
BookingCard as BookingCardData,
BookingItem,
BookingLeg,
BookingStay,
BookingTicket,
} from '@trek/shared';
import { useTranslation } from '../../../../i18n';
import { TypeTile } from '../../../Planner/bookings/bookingParts';
import { WhiteBadge } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry, EntityTarget } from '../cardTypes';
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
import BookingCard from './BookingCard';
import { FULL_CARDS, itemsOf } from './bookingCardModel';
import { RangeArrow, StatusMark } from './bookingCardParts';
import { bookingTabOf, bookingTarget } from './bookingLook';
import { useBookingFormat } from './useBookingFormat';
/**
* The bookings of one answer, or of one change, as one block: up to two whole
* cards stacked, from three on one framed list in the plan sidebar's look, so
* a file with five bookings stays short and nothing folds away. What the
* answer left out is counted as a way to the bookings tab.
*/
type Card = Pick<BookingCardData, 'change'>;
/** When a ticket goes: its day, departure and arrival. */
function TicketWhen({ body }: { body: BookingTicket }) {
const format = useBookingFormat();
const leg = body.legs[0] as BookingLeg;
const end = body.legs[body.legs.length - 1] as BookingLeg;
return (
<>
<span>{format.day(leg.departsDay)}</span>
<span>{format.clock(leg.departs)}</span>
{leg.departs && end.arrives && <RangeArrow />}
<span>{format.clock(end.arrives)}</span>
</>
);
}
/** When a stay is: check-in day to check-out day, and its nights. */
function StayWhen({ body }: { body: BookingStay }) {
const { t } = useTranslation();
const format = useBookingFormat();
const start = body.checkIn ? body.checkIn.day : null;
const end = body.checkOut ? body.checkOut.day : null;
return (
<>
<span>{format.day(start)}</span>
{start && end && <RangeArrow />}
<span>{format.day(end)}</span>
{body.nights ? <span>{t('assistant.card.booking.nights', { count: body.nights })}</span> : null}
</>
);
}
/** The second line of a row: when, in a few words, and another trip's name. */
function RowFacts({ item }: { item: BookingItem }) {
const { t } = useTranslation();
const format = useBookingFormat();
const body = item.body;
return (
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-caption tabular-nums text-content-faint">
{body.shape === 'ticket' && <TicketWhen body={body} />}
{body.shape === 'stay' && <StayWhen body={body} />}
{body.shape === 'venue' && (
<>
<span>{format.day(body.day)}</span>
<span>{format.clock(body.starts)}</span>
</>
)}
{item.tripTitle && <span>{t('assistant.card.booking.inTrip', { trip: item.tripTitle })}</span>}
</span>
);
}
/** One booking as a row of the list: its type, its title and status, when it is. */
function BookingRow({ item, card }: { item: BookingItem; card: Card }) {
const { onOpen } = useAssistantView();
const target = item.reservationId !== null ? bookingTarget(item, item.reservationId) : null;
return (
<CardRow
lead={<TypeTile type={item.type} size={24} />}
title={
<span className="flex min-w-0 items-center gap-1.5">
<bdi title={item.title} className={`line-clamp-1 ${card.change === 'remove' ? 'line-through' : ''}`}>
{item.title}
</bdi>
<StatusMark item={item} />
</span>
}
facts={<RowFacts item={item} />}
onActivate={onOpen && target ? () => onOpen(target) : undefined}
/>
);
}
/**
* Where "N more" leads: the tab of the bookings it left out, the transports
* when all shown are; a list spanning other trips (the upcoming bookings) leads
* to the dashboard, which lists every trip.
*/
function moreTarget(items: readonly BookingItem[]): EntityTarget {
if (items.some((item) => item.tripTitle !== null)) return { kind: 'page', path: '/dashboard' };
const tab = items.every((item) => bookingTabOf(item.type) === 'transports') ? 'transports' : 'buchungen';
return { kind: 'tab', tab };
}
/** Bookings as whole cards (two at most) or as a framed list. */
export function BookingList({
items,
more,
card,
actions,
}: {
items: readonly BookingItem[];
more: number;
card: Card;
actions: boolean;
}) {
const { t } = useTranslation();
if (items.length === 0) return null;
if (items.length <= FULL_CARDS) {
return (
<div className="flex flex-col gap-2">
{items.map((item, i) => (
<BookingCard key={`${item.reservationId ?? 'new'}-${i}`} item={item} card={card} actions={actions} />
))}
<CardMore count={more} labelKey="assistant.card.booking.more" target={moreTarget(items)} />
</div>
);
}
return (
<section
aria-label={t('assistant.card.booking.list')}
className={`trek-assistant-enter rounded-xl border bg-surface-card px-3 pb-2 pt-2.5 shadow-card ${
card.change === 'create' ? 'border-dashed border-edge' : 'border-edge-faint'
}`}
>
<div className="flex items-center gap-1.5">
<span className="text-caption font-semibold text-content-secondary">{t('assistant.card.booking.list')}</span>
<WhiteBadge>{items.length + more}</WhiteBadge>
</div>
<CardRows>
{items.map((item, i) => (
<BookingRow key={`${item.reservationId ?? 'new'}-${i}`} item={item} card={card} />
))}
</CardRows>
<CardMore count={more} labelKey="assistant.card.booking.more" target={moreTarget(items)} />
</section>
);
}
/** The answer block: every booking card of the group read as one list. */
export default function BookingBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
const { items, more } = itemsOf(cards);
return <BookingList items={items} more={more} card={{ change: 'none' }} actions />;
}
@@ -0,0 +1,415 @@
/**
* FE-AI-BOOKING-001 to FE-AI-BOOKING-019
*
* The booking cards in the chat: a ticket with its route, times and legs, a
* stay with its nights and check-in window, a venue with its place and time;
* the status dot, the review mark and the travellers; the frame of a create,
* an update and a removal; a list from three bookings on; and the ways to the
* plan's map and to the booking itself, in the window and on the phone.
*/
import type {
AssistantMessage,
BookingCard as BookingCardData,
BookingItem,
BookingLeg,
BookingStay,
BookingTicket,
} from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor, within } from '../../../../../tests/helpers/render';
import { fetchPhoto } from '../../../../services/photoService';
import { useAuthStore } from '../../../../store/authStore';
import { useSettingsStore } from '../../../../store/settingsStore';
import type { ChangeItem } from '../../assistantParts';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import BookingBlock from './BookingBlock';
import { BookingChangeBody, BookingChangeFacts } from './BookingChangeSlots';
vi.mock('../../../../services/photoService', () => ({
fetchPhoto: vi.fn(),
getCached: vi.fn(() => undefined),
}));
const AUTH = useAuthStore.getState();
const SETTINGS = useSettingsStore.getState();
const RealObserver = globalThis.IntersectionObserver;
const day = (id: number, number: number, date: string) => ({ id, number, date });
const FLIGHT: BookingItem = {
tripId: 4,
tripTitle: null,
reservationId: 501,
type: 'flight',
title: 'Frankfurt to New York',
status: 'confirmed',
needsReview: false,
hasReference: true,
price: { amount: 420, currency: 'EUR' },
travelers: [
{ id: 7, name: 'mia', avatarKey: null },
{ id: 8, name: 'jon', avatarKey: null },
],
moreTravelers: 0,
changed: [],
body: {
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57 },
{ name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 },
],
legs: [
{
carrier: 'Lufthansa',
number: 'LH 400',
departs: '08:15',
departsDay: day(31, 1, '2026-06-03'),
arrives: '11:05',
arrivesDay: day(32, 2, '2026-06-04'),
minutes: 530,
seat: '12A',
platform: null,
},
],
minutes: 530,
mappable: true,
},
};
const STOPOVER: BookingItem = {
...FLIGHT,
reservationId: 502,
body: {
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57 },
{ name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.79 },
{ name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 },
],
legs: [
{
carrier: 'Lufthansa',
number: 'LH 101',
departs: '08:15',
departsDay: day(31, 1, '2026-06-03'),
arrives: '09:10',
arrivesDay: day(31, 1, '2026-06-03'),
minutes: 55,
seat: '12A',
platform: null,
},
{
carrier: 'Lufthansa',
number: 'LH 410',
departs: '10:30',
departsDay: day(31, 1, '2026-06-03'),
arrives: '13:05',
arrivesDay: day(31, 1, '2026-06-03'),
minutes: null,
seat: null,
platform: null,
},
],
minutes: null,
mappable: true,
},
};
const STAY: BookingItem = {
tripId: 4,
tripTitle: null,
reservationId: null,
type: 'hotel',
title: 'Mercure Hotel Berlin City',
status: null,
needsReview: true,
hasReference: false,
price: null,
travelers: [],
moreTravelers: 0,
changed: [],
body: {
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
photoKey: 'ChIJmercure',
checkIn: { day: day(31, 1, '2026-06-03'), from: '15:00', until: '20:00' },
checkOut: { day: day(33, 3, '2026-06-05'), by: '11:00' },
nights: 2,
},
};
const DINNER: BookingItem = {
...STAY,
reservationId: 503,
type: 'restaurant',
title: 'Dinner at Borchardt',
status: 'pending',
needsReview: false,
body: {
shape: 'venue',
day: day(32, 2, '2026-06-04'),
starts: '19:00',
ends: '21:30',
location: 'Borchardt',
placeId: 13,
lat: 52.51,
lng: 13.39,
},
};
const MESSAGE: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'complete',
createdAt: '2026-06-01T10:00:00.000Z',
parts: [],
};
function cardOf(items: BookingItem[], change: BookingCardData['change'] = 'none', more = 0): BookingCardData {
return { kind: 'booking', change, items, more };
}
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', density: 'roomy', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
function answer(items: BookingItem[], view: Partial<AssistantView> = {}, more = 0) {
const cards = [{ toolCallId: 'tc-1', card: cardOf(items, 'none', more) }];
return inView(<BookingBlock cards={cards} message={MESSAGE} />, view);
}
function changeItem(card: BookingCardData): ChangeItem {
return {
toolCallId: 'tc-w',
preview: {
toolCallId: 'tc-w',
verbKey: 'updateReservation',
subject: 'Dinner',
rows: [{ op: 'change', labelKey: 'time', before: '19:00', after: '20:00' }],
dayId: null,
placeIds: [],
undoable: false,
effect: null,
},
applied: null,
state: 'pending',
card,
};
}
beforeEach(() => {
useAuthStore.setState({ user: buildUser({ id: 7 }), placesPhotosEnabled: true });
});
afterEach(() => {
globalThis.IntersectionObserver = RealObserver;
useAuthStore.setState(AUTH, true);
useSettingsStore.setState(SETTINGS, true);
vi.clearAllMocks();
});
describe('booking cards', () => {
it('FE-AI-BOOKING-001: a ticket shows its codes, times, duration, carrier, seat and a +1 for a later arrival', () => {
answer([FLIGHT]);
expect(screen.getByText('FRA')).toBeInTheDocument();
expect(screen.getByText('JFK')).toBeInTheDocument();
expect(screen.getByText('08:15')).toBeInTheDocument();
expect(screen.getByText('8h 50m')).toBeInTheDocument();
expect(screen.getByText('Lufthansa LH 400')).toBeInTheDocument();
expect(screen.getByText('Seat 12A')).toBeInTheDocument();
expect(screen.getByText('Direct')).toBeInTheDocument();
expect(screen.getByLabelText('Arrives 1 day later')).toHaveTextContent('+1');
expect(screen.getByText('Reference saved')).toBeInTheDocument();
expect(screen.queryByText('ABC123')).not.toBeInTheDocument();
});
it('FE-AI-BOOKING-002: clock times follow the 12 hour setting', () => {
useSettingsStore.setState((s) => ({ settings: { ...s.settings, time_format: '12h' } }));
answer([FLIGHT]);
expect(screen.getByText('8:15 AM')).toBeInTheDocument();
});
it('FE-AI-BOOKING-003: a stopover booking lists its legs and the time between them', () => {
answer([STOPOVER]);
expect(screen.getByText(/LH 101/)).toBeInTheDocument();
expect(screen.getByText(/LH 410/)).toBeInTheDocument();
expect(screen.getByText('1h 20m in MUC')).toBeInTheDocument();
expect(screen.getByText('1 stop')).toBeInTheDocument();
});
it('FE-AI-BOOKING-004: a stay shows its nights, check-in green, check-out red, its arrow turned for RTL', () => {
answer([STAY]);
expect(screen.getByText('2 nights')).toBeInTheDocument();
const checkIn = screen.getByText(/Check-in 15:00/).closest('span.inline-flex') as HTMLElement;
expect(checkIn.className).toContain('text-success');
const checkOut = screen.getByText(/Check-out 11:00/).closest('span.inline-flex') as HTMLElement;
expect(checkOut.className).toContain('text-danger');
// The range is read out in words; the dates and the arrow beside it are hidden from screen readers.
const range = screen.getByText(/to Fri/);
expect(range.className).toContain('sr-only');
const shown = range.nextElementSibling as HTMLElement;
expect(shown).toHaveAttribute('aria-hidden', 'true');
expect(shown.querySelector('svg')?.getAttribute('class')).toContain('rtl:-scale-x-100');
expect(screen.getByText('Invalidenstraße 38')).toBeInTheDocument();
});
it("FE-AI-BOOKING-005: a stay's picture comes only through TREK's photo proxy", async () => {
globalThis.IntersectionObserver = class {
private readonly callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe() {
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
const photoUrl = key === 'ChIJmercure' ? '/api/maps/place-photo/ChIJmercure' : 'https://maps.example/x.jpg';
callback?.({ photoUrl, thumbDataUrl: null });
});
const otherBody: BookingStay = { ...(STAY.body as BookingStay), photoKey: 'ChIJother' };
const other: BookingItem = { ...STAY, title: 'Other', body: otherBody };
const { container } = answer([STAY, other]);
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/ChIJmercure');
expect(fetchPhoto).toHaveBeenCalledWith('ChIJmercure', 'ChIJmercure', 52.53, 13.38, STAY.title, expect.anything());
});
it('FE-AI-BOOKING-006: a venue shows where it is and its time range', () => {
answer([DINNER]);
expect(screen.getByText('Borchardt')).toBeInTheDocument();
expect(screen.getByText('19:00')).toBeInTheDocument();
expect(screen.getByText('21:30')).toBeInTheDocument();
});
it('FE-AI-BOOKING-007: the status dot names its status, none without one; one to check wears its review mark', () => {
answer([FLIGHT, { ...DINNER, status: 'cancelled' }]);
expect(screen.getByRole('img', { name: 'Confirmed' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Cancelled' })).toBeInTheDocument();
answer([STAY]);
expect(screen.queryByRole('img', { name: 'Pending' })).not.toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Review' })).toBeInTheDocument();
});
it('FE-AI-BOOKING-017: a stopover of unknown length names only its place; each leg names its carrier and seat', () => {
const body = STOPOVER.body as BookingTicket;
const onward = { ...(body.legs[1] as BookingLeg), departs: null };
answer([{ ...STOPOVER, body: { ...body, legs: [body.legs[0] as BookingLeg, onward] } }]);
expect(screen.queryByText(/ in MUC/)).not.toBeInTheDocument();
expect(screen.getAllByText('MUC').some((el) => el.className.includes('rounded-full'))).toBe(true);
expect(screen.getByText('Lufthansa LH 101')).toBeInTheDocument();
expect(screen.getByText('Seat 12A')).toBeInTheDocument();
});
it('FE-AI-BOOKING-018: a transit hop has a dot with no word; a stay its photo slot in the head with the address', () => {
answer([{ ...FLIGHT, type: 'transit', status: null }]);
expect(screen.queryByRole('img', { name: /Confirmed|Pending|Cancelled/ })).not.toBeInTheDocument();
answer([STAY]);
const head = screen.getByText('Mercure Hotel Berlin City').closest('div') as HTMLElement;
expect(within(head).getByText('Invalidenstraße 38')).toBeInTheDocument();
});
it('FE-AI-BOOKING-019: "N more" of a list spanning other trips leads to the dashboard', () => {
const onOpen = vi.fn();
answer([{ ...DINNER, tripId: 9, tripTitle: 'Vienna' }, FLIGHT], { onOpen }, 3);
fireEvent.click(screen.getByText('3 more bookings'));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/dashboard' });
});
it('FE-AI-BOOKING-008: the travellers stack names everyone, the signed-in user as You', () => {
answer([FLIGHT]);
expect(screen.getByRole('img', { name: 'You, jon' })).toBeInTheDocument();
});
it('FE-AI-BOOKING-009: a booking to create sits in a dashed frame, one to delete is struck and faded', () => {
const toCreate = cardOf([DINNER], 'create');
const created = inView(<BookingChangeBody card={toCreate} item={changeItem(toCreate)} />);
expect(created.container.querySelector('section')?.className).toContain('border-dashed');
created.unmount();
const toRemove = cardOf([DINNER], 'remove');
const removed = inView(<BookingChangeBody card={toRemove} item={changeItem(toRemove)} />);
expect(removed.container.querySelector('section')?.className).toContain('opacity-60');
expect(screen.getByText('Dinner at Borchardt').className).toContain('line-through');
expect(screen.getByText('Will be deleted')).toBeInTheDocument();
});
it('FE-AI-BOOKING-010: an update marks the facts it changes and keeps their before and after on the row', () => {
const card = cardOf([{ ...DINNER, changed: ['time', 'status'] }], 'update');
inView(
<>
<BookingChangeFacts card={card} item={changeItem(card)} />
<BookingChangeBody card={card} item={changeItem(card)} />
</>
);
expect(screen.getAllByRole('img', { name: 'Changed' })).toHaveLength(2);
expect(screen.getByText('20:00')).toBeInTheDocument();
const created = cardOf([DINNER], 'create');
const { container } = inView(<BookingChangeFacts card={created} item={changeItem(created)} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-BOOKING-011: three bookings or more read as one list, with the rest counted as a way to the tab', () => {
const onOpen = vi.fn();
answer([FLIGHT, STAY, DINNER], { onOpen }, 2);
const list = screen.getByRole('region', { name: 'Bookings' });
expect(within(list).getByText('5')).toBeInTheDocument();
expect(within(list).getByText('2 nights')).toBeInTheDocument();
fireEvent.click(within(list).getByText('2 more bookings'));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'buchungen' });
fireEvent.click(within(list).getByText('Frankfurt to New York'));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'transports', params: { booking: '501' } });
});
it('FE-AI-BOOKING-012: Open goes to the booking in its tab and Show on map draws its route on the plan', () => {
const onOpen = vi.fn();
answer([FLIGHT], { onOpen });
fireEvent.click(screen.getByRole('button', { name: 'Open the booking' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'transports', params: { booking: '501' } });
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'plan', params: { booking: '501', map: '1' } });
});
it('FE-AI-BOOKING-013: a stay shows its place on the map and opens on its check-in day', () => {
const onOpen = vi.fn();
answer([STAY], { onOpen });
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
fireEvent.click(screen.getByRole('button', { name: 'Show in the plan' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: null, dayId: 31 });
});
it("FE-AI-BOOKING-014: a booking of another trip names that trip and opens that trip's planner", () => {
const onOpen = vi.fn();
answer([{ ...DINNER, tripId: 9, tripTitle: 'Vienna' }], { onOpen });
expect(screen.getByText('In Vienna')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open the booking' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/trips/9?tab=buchungen&booking=503' });
});
it('FE-AI-BOOKING-015: on the phone the actions are tall; without a way to open them there are none', () => {
answer([FLIGHT], { variant: 'fullscreen', onOpen: vi.fn() });
expect(screen.getByRole('button', { name: 'Open the booking' }).className).toContain('h-10');
answer([DINNER]);
expect(screen.queryAllByRole('button', { name: 'Open the booking' })).toHaveLength(1);
});
it('FE-AI-BOOKING-016: inside a change card the booking has no actions of its own', () => {
const card = cardOf([FLIGHT], 'none');
inView(<BookingChangeBody card={card} item={changeItem(card)} />, { onOpen: vi.fn() });
expect(screen.queryByRole('button', { name: 'Open the booking' })).not.toBeInTheDocument();
expect(screen.getByText('Frankfurt to New York')).toBeInTheDocument();
});
});
@@ -0,0 +1,118 @@
import type { BookingCard as BookingCardData, BookingItem } from '@trek/shared';
import { ArrowUpRight, MapPin, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { Tooltip } from '../../../shared/Tooltip';
import { useAssistantView } from '../../assistantViewContext';
import type { EntityTarget } from '../cardTypes';
import { BookingFooter, BookingHead, StayBody, TicketBody, VenueBody } from './bookingCardParts';
import { bookingSpotOf, bookingTarget } from './bookingLook';
/**
* One booking as a whole card, the way the planner's booking cards look: a
* head band tinted by its status, then the ticket, stay or venue, then its
* price, reference and travellers. A booking that will be created sits in a
* dashed frame, one that will be deleted fades with its title struck. One
* component for the desktop window and the phone.
*/
type Card = Pick<BookingCardData, 'change'>;
const FRAME: Record<Card['change'], string> = {
none: 'border-edge-faint',
create: 'border-dashed border-edge',
update: 'border-edge-faint',
remove: 'border-edge-faint opacity-60',
};
/** Where the card's "Open" goes: the booking in its tab, a stay without a booking on its check-in day. */
function openTarget(item: BookingItem): EntityTarget | null {
if (item.reservationId !== null) return bookingTarget(item, item.reservationId);
const dayId = item.body.shape === 'stay' ? (item.body.checkIn?.day.id ?? null) : null;
return dayId !== null && item.tripTitle === null ? { kind: 'map', placeId: null, dayId } : null;
}
/** Where "Show on map" goes: a saved route drawn on the plan, a stay's or a venue's place. */
function mapTarget(item: BookingItem): EntityTarget | null {
const body = item.body;
if (body.shape === 'ticket') {
return body.mappable && item.reservationId !== null ? bookingTarget(item, item.reservationId, true) : null;
}
const spot = bookingSpotOf(item);
return spot?.placeId != null ? { kind: 'map', ...spot } : null;
}
interface HeadActionProps {
label: string;
onClick: () => void;
icon: LucideIcon;
/** A pointing glyph turns with the reading direction; a map pin does not. */
turns?: boolean;
}
/** A quiet round action on the head band: shown on hover or focus, always on touch screens; tall on the phone. */
function HeadAction({ label, onClick, icon: Icon, turns = false }: HeadActionProps) {
const { variant } = useAssistantView();
const size = variant === 'fullscreen' ? 'h-10 w-10' : 'h-[26px] w-[26px]';
return (
<Tooltip label={label}>
<button
type="button"
onClick={onClick}
aria-label={label}
className={`grid ${size} flex-none place-items-center rounded-full bg-surface-card text-content-muted opacity-60 transition-opacity hover:text-content focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100`}
>
<Icon size={14} aria-hidden="true" className={turns ? 'rtl:-scale-x-100' : undefined} />
</button>
</Tooltip>
);
}
/** The ways out of a card: the plan's map, and the booking where it lives. */
function CardActions({ item }: { item: BookingItem }) {
const { t } = useTranslation();
const { onOpen } = useAssistantView();
if (!onOpen) return null;
const map = mapTarget(item);
const open = openTarget(item);
const openKey = item.reservationId !== null ? 'assistant.card.booking.open' : 'assistant.card.booking.openStay';
return (
<>
{map && <HeadAction label={t('assistant.change.showOnMap')} icon={MapPin} onClick={() => onOpen(map)} />}
{open && <HeadAction label={t(openKey)} icon={ArrowUpRight} turns onClick={() => onOpen(open)} />}
</>
);
}
export default function BookingCard({
item,
card,
actions = true,
}: {
item: BookingItem;
card: Card;
/** Off inside a change card, whose row has its own ways to the booking. */
actions?: boolean;
}) {
const { t } = useTranslation();
const body = item.body;
return (
<section
aria-label={item.title}
className={`trek-assistant-enter group overflow-hidden rounded-xl border bg-surface-card shadow-card ${FRAME[card.change]}`}
>
<BookingHead item={item} card={card} actions={actions ? <CardActions item={item} /> : null} />
<div className="px-3 pb-2.5 pt-2">
{item.tripTitle && (
<p className="m-0 mb-1 text-caption text-content-muted">
{t('assistant.card.booking.inTrip', { trip: item.tripTitle })}
</p>
)}
{body.shape === 'ticket' && <TicketBody item={item} body={body} card={card} />}
{body.shape === 'stay' && <StayBody item={item} body={body} card={card} />}
{body.shape === 'venue' && <VenueBody item={item} body={body} card={card} />}
<BookingFooter item={item} card={card} />
</div>
</section>
);
}
@@ -0,0 +1,29 @@
import type { TrekCardOf } from '@trek/shared';
import AssistantChangeFields from '../../AssistantChangeFields';
import type { ChangeItem } from '../../assistantParts';
import { BookingList } from './BookingBlock';
/**
* The booking card inside a change row. The row keeps its choice, its tile
* and the verb line ("Add accommodation"); the card under it names the
* booking, so the row's bold subject gives way. An update keeps the before
* and after pills of its fields on the faint line; a create or a removal shows
* them in the card alone.
*/
interface SlotProps {
card: TrekCardOf<'booking'>;
item: ChangeItem;
}
/** The card under the row; the row itself leads to the booking once it is saved. */
export function BookingChangeBody({ card }: SlotProps) {
return <BookingList items={card.items} more={card.more} card={card} actions={false} />;
}
/** The faint line's pills: an update's old and new values, nothing beside a card that shows them all. */
export function BookingChangeFacts({ card, item }: SlotProps) {
if (card.change !== 'update' || !item.preview) return null;
return <AssistantChangeFields rows={item.preview.rows} />;
}
@@ -0,0 +1,100 @@
import type { BookingCard, BookingChangedField, BookingItem, BookingLeg, BookingTicket, CardDay } from '@trek/shared';
import type { StatusTone } from '../../../Planner/bookings/bookingParts';
import type { CardEntry } from '../cardTypes';
/**
* The pure side of a booking card: what its status dot says, how many stops
* a ticket makes, how long a stopover lasts, how many days later it lands,
* and how an answer's booking cards read as one list. No React here, so the
* card, its rows and the tests read one answer.
*/
/** A status a booking card names in words. */
type BookingStatus = NonNullable<BookingItem['status']>;
/**
* The planner's tone of a booking (its `toneOf`): a transit hop in the info
* tone, a confirmed booking green, any other (pending, cancelled) amber; none
* for a stay no booking names.
*/
export function toneOfItem(item: Pick<BookingItem, 'type' | 'status'>): StatusTone | null {
if (item.type === 'transit') return 'transit';
if (!item.status) return null;
return item.status === 'confirmed' ? 'confirmed' : 'pending';
}
/** The words for a status, literal keys so the i18n check sees each one; a cancelled one says so in its tooltip. */
export const STATUS_LABEL: Record<BookingStatus, { labelKey: string }> = {
confirmed: { labelKey: 'reservations.confirmed' },
pending: { labelKey: 'reservations.pending' },
cancelled: { labelKey: 'assistant.card.booking.cancelled' },
};
/** The stops between a ticket's two ends; null when no route is known. */
export function stopsOf(ticket: BookingTicket): number | null {
if (ticket.stops.length >= 2) return ticket.stops.length - 2;
return ticket.legs.length > 1 ? ticket.legs.length - 1 : null;
}
function dayNumber(date: string): number {
return Math.round(Date.parse(`${date}T00:00:00Z`) / 86_400_000);
}
/** How many days after it departs a leg (or a whole ticket, first leg to last) lands; null for the same day. */
export function daysLater(from: CardDay | null, to: CardDay | null): number | null {
if (from?.date && to?.date) {
const later = dayNumber(to.date) - dayNumber(from.date);
return later > 0 ? later : null;
}
if (from?.number != null && to?.number != null) {
const later = to.number - from.number;
return later > 0 ? later : null;
}
return null;
}
/** The minutes between two clocks on the days they name (a stopover's arrival and the onward departure). */
function minutesBetween(arrives: BookingLeg, departs: BookingLeg): number | null {
if (!arrives.arrives || !departs.departs) return null;
const toMinutes = (clock: string) => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));
const days = daysLater(arrives.arrivesDay ?? arrives.departsDay, departs.departsDay ?? arrives.arrivesDay) ?? 0;
const minutes = days * 1440 + toMinutes(departs.departs) - toMinutes(arrives.arrives);
return minutes > 0 ? minutes : null;
}
/** One stopover of a ticket: where, and for how long when both clocks are known. */
interface Layover {
place: string;
minutes: number | null;
}
/** The stopovers between a ticket's legs; only when the legs line up with its stops. */
export function layoversOf(ticket: BookingTicket): Layover[] {
if (ticket.legs.length < 2 || ticket.stops.length !== ticket.legs.length + 1) return [];
return ticket.legs.slice(0, -1).map((leg, i) => {
const stop = ticket.stops[i + 1];
const next = ticket.legs[i + 1] as BookingLeg;
return { place: stop?.code ?? stop?.name ?? '', minutes: minutesBetween(leg, next) };
});
}
/** Whether an update changes a field; a create or a removal marks nothing. */
export function isChanged(card: Pick<BookingCard, 'change'>, item: BookingItem, field: BookingChangedField): boolean {
return card.change === 'update' && item.changed.includes(field);
}
/** An answer's booking cards as one list: every booking in order, and how many the answers left out. */
export function itemsOf(entries: readonly CardEntry[]): { items: BookingItem[]; more: number } {
const items: BookingItem[] = [];
let more = 0;
for (const entry of entries) {
if (entry.card.kind !== 'booking') continue;
items.push(...entry.card.items);
more += entry.card.more;
}
return { items, more };
}
/** At most this many bookings are drawn as whole cards; from one more on they read as a list. */
export const FULL_CARDS = 2;
@@ -0,0 +1,494 @@
import type {
BookingCard as BookingCardData,
BookingItem,
BookingLeg,
BookingStay,
BookingTicket,
BookingVenue,
} from '@trek/shared';
import {
ArrowRight,
BadgeCheck,
CalendarDays,
Clock,
LogIn,
LogOut,
MapPin,
Moon,
Route,
Ticket,
Wallet,
type LucideIcon,
} from 'lucide-react';
import { useRef, type ReactNode } from 'react';
import { useTranslation } from '../../../../i18n';
import { ReviewPill, toneColor, toneTint, TypeTile } from '../../../Planner/bookings/bookingParts';
import { typeInfo } from '../../../Planner/bookings/bookingsModel';
import { SoftPill, TimePill, type PillTone } from '../../../Planner/planParts';
import { Tooltip } from '../../../shared/Tooltip';
import { useAssistantView } from '../../assistantViewContext';
import { useFoundPlacePhoto } from '../../useFoundPlacePhoto';
import { CardFacts } from '../parts/CardFacts';
import { PeopleStack } from '../parts/CardPeople';
import { daysLater, isChanged, layoversOf, STATUS_LABEL, stopsOf, toneOfItem } from './bookingCardModel';
import { useBookingFormat } from './useBookingFormat';
/**
* The pieces of a booking card, in the planner's own booking language: the
* head band tinted by the status, the type tile, the review mark, white soft
* pills for the facts, the travellers' faces at the end. A ticket shows its
* route with times and codes, a stay its nights and check-in window, a venue
* its place and time. Codes and times never truncate; names do, with their
* whole text in a tooltip.
*/
type Card = Pick<BookingCardData, 'change'>;
/** The arrow of a range (a check-in window, Monday to Sunday), turned for right-to-left. */
export function RangeArrow() {
return <ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />;
}
/** The small accent dot of a fact an update changes. */
function ChangedDot() {
const { t } = useTranslation();
const label = t('assistant.card.common.changed');
return (
<span
role="img"
aria-label={label}
title={label}
className="inline-block h-1.5 w-1.5 flex-none rounded-full bg-accent"
/>
);
}
/**
* The status dot in the planner's tones (its `toneOf`), its word in the
* tooltip; a transit hop has the dot without a word, a stay no booking names
* neither.
*/
export function StatusMark({ item }: { item: BookingItem }) {
const { t } = useTranslation();
const tone = toneOfItem(item);
if (!tone) return null;
const dot = <span className="block h-2 w-2 rounded-full" style={{ background: toneColor(tone) }} />;
if (!item.status) return <span className="grid h-[22px] w-[22px] flex-none place-items-center">{dot}</span>;
const label = t(STATUS_LABEL[item.status].labelKey);
return (
<Tooltip label={label}>
<span role="img" aria-label={label} className="grid h-[22px] w-[22px] flex-none place-items-center">
{dot}
</span>
</Tooltip>
);
}
/** A stay's picture through TREK's photo proxy, else its type tile at `tile`. */
function StayPhoto({ item, body, size, tile }: { item: BookingItem; body: BookingStay; size: number; tile: number }) {
const ref = useRef<HTMLSpanElement>(null);
const photo = useFoundPlacePhoto({ photoKey: body.photoKey, lat: body.lat, lng: body.lng, name: item.title }, ref);
return (
<span ref={ref} className="flex flex-none">
{photo ? (
<img src={photo} alt="" className="rounded-lg object-cover" style={{ width: size, height: size }} />
) : (
<TypeTile type={item.type} size={tile} />
)}
</span>
);
}
/** The lead of the head band: a stay's photo (56, 44 when compact), else the type tile. */
function HeadLead({ item }: { item: BookingItem }) {
const { density } = useAssistantView();
const compact = density === 'compact';
if (item.body.shape === 'stay') {
return <StayPhoto item={item} body={item.body} size={compact ? 44 : 56} tile={compact ? 32 : 40} />;
}
return <TypeTile type={item.type} size={compact ? 24 : 28} />;
}
/**
* The head band: the type tile (a stay's photo with its address under the
* title), the title, the review mark, the status dot and the card's actions,
* on the tint of the status as on the planner's booking cards.
*/
export function BookingHead({ item, card, actions }: { item: BookingItem; card: Card; actions?: ReactNode }) {
const { t } = useTranslation();
const tone = toneOfItem(item);
const removed = card.change === 'remove';
const address = item.body.shape === 'stay' ? item.body.address : null;
return (
<div
className="flex items-center gap-2 border-b border-edge-faint px-3 py-2"
style={{ background: tone ? toneTint(tone) : undefined }}
>
<HeadLead item={item} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-1.5">
<bdi
title={item.title}
className={`line-clamp-2 text-body font-semibold leading-tight text-content ${removed ? 'line-through' : ''}`}
>
{item.title}
</bdi>
{item.needsReview && <ReviewPill />}
{isChanged(card, item, 'title') && <ChangedDot />}
{removed && <span className="sr-only">{t('assistant.card.common.willDelete')}</span>}
</span>
{address && (
<bdi title={address} className="line-clamp-2 text-caption text-content-muted">
{address}
</bdi>
)}
</span>
<StatusMark item={item} />
{isChanged(card, item, 'status') && <ChangedDot />}
{actions}
</div>
);
}
/** A fact as a white soft pill with its glyph, the accent dot when an update changes it. */
function Pill({
icon: Icon,
tone = 'neutral',
changed = false,
label,
children,
}: {
icon: LucideIcon;
tone?: PillTone;
changed?: boolean;
label?: string;
children: ReactNode;
}) {
return (
<SoftPill
tone={tone}
className="max-w-full tabular-nums"
icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
>
{label && <span className="sr-only">{label}</span>}
<span className="flex min-w-0 items-center gap-1" aria-hidden={label ? true : undefined}>
{children}
</span>
{changed && <ChangedDot />}
</SoftPill>
);
}
/** The day a booking falls on: its date, and its number in the trip beside it. */
function DayPills({ day, changed }: { day: BookingLeg['departsDay']; changed: boolean }) {
const format = useBookingFormat();
const date = format.day(day);
const number = format.dayNumber(day);
if (!date) return null;
return (
<>
<Pill icon={CalendarDays} changed={changed}>
<bdi className="truncate">{date}</bdi>
</Pill>
{number && <SoftPill>{number}</SoftPill>}
</>
);
}
/** What every booking ends with: its price, that a reference is kept, and who travels. */
export function BookingFooter({ item, card }: { item: BookingItem; card: Card }) {
const { t } = useTranslation();
const format = useBookingFormat();
if (!item.price && !item.hasReference && item.travelers.length === 0) return null;
return (
<div className="mt-1.5 flex items-center gap-2">
<CardFacts>
{item.price && (
<Pill icon={Wallet} changed={isChanged(card, item, 'price')}>
{format.money(item.price)}
</Pill>
)}
{item.hasReference && (
<Tooltip label={t('assistant.card.booking.referenceHint')}>
<span className="inline-flex">
<Pill icon={BadgeCheck} tone="success" changed={isChanged(card, item, 'reference')}>
{t('assistant.card.booking.reference')}
</Pill>
</span>
</Tooltip>
)}
</CardFacts>
<span className="ms-auto flex flex-none items-center gap-1">
<PeopleStack people={item.travelers} />
{item.moreTravelers > 0 && (
<span className="text-caption font-semibold tabular-nums text-content-faint">+{item.moreTravelers}</span>
)}
{isChanged(card, item, 'travelers') && <ChangedDot />}
</span>
</div>
);
}
/** "08:15 → 11:05 +1" for a ticket without a route (a hire car, a taxi). */
function TimesPill({ leg, later }: { leg: BookingLeg; later: number | null }) {
const format = useBookingFormat();
if (!leg.departs && !leg.arrives) return null;
return (
<TimePill>
<span>{format.clock(leg.departs)}</span>
{leg.departs && leg.arrives && <RangeArrow />}
<span>{format.clock(leg.arrives)}</span>
{later !== null && <Later count={later} />}
</TimePill>
);
}
/** "+1" after an arrival on a later day, the day count in words in its tooltip. */
function Later({ count }: { count: number }) {
const { t } = useTranslation();
const label = t('assistant.card.booking.arrivesLater', { count });
return (
<Tooltip label={label}>
<sup aria-label={label} className="ms-0.5 text-caption font-semibold text-content-muted">
+{count}
</sup>
</Tooltip>
);
}
/** The route of a ticket as the planner draws it: times above, codes on a dashed line, names below. */
function RouteLine({ item, body, later }: { item: BookingItem; body: BookingTicket; later: number | null }) {
const format = useBookingFormat();
const { density } = useAssistantView();
const first = body.stops[0];
const last = body.stops[body.stops.length - 1];
const leg = body.legs[0] as BookingLeg;
const end = body.legs[body.legs.length - 1] as BookingLeg;
const info = typeInfo(item.type);
const time = `font-semibold tabular-nums text-content ${density === 'compact' ? 'text-body' : 'text-subtitle'}`;
if (!first || !last) return null;
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5">
<span className={time}>{format.clock(leg.departs)}</span>
<span className="truncate text-center text-caption text-content-muted">{format.minutes(body.minutes)}</span>
<span className={`${time} text-end`}>
{format.clock(end.arrives)}
{later !== null && <Later count={later} />}
</span>
<span className="text-caption font-bold text-content">{first.code ?? ''}</span>
<span className="flex items-center gap-1" aria-hidden="true">
<span className="h-px flex-1 border-t border-dashed border-edge" />
<info.Icon size={13} strokeWidth={2.2} className="flex-none rtl:-scale-x-100" style={{ color: info.color }} />
<span className="h-px flex-1 border-t border-dashed border-edge" />
</span>
<span className="text-end text-caption font-bold text-content">{last.code ?? ''}</span>
<bdi title={first.name} className="truncate text-caption text-content-faint">
{first.name}
</bdi>
<span />
<bdi title={last.name} className="truncate text-end text-caption text-content-faint">
{last.name}
</bdi>
</div>
);
}
/** Who flies a leg and where one sits: "Lufthansa LH 101", then "Seat 12A". */
function LegCarrier({ leg }: { leg: BookingLeg }) {
const { t } = useTranslation();
const carrier = [leg.carrier, leg.number].filter(Boolean).join(' ');
if (!carrier && !leg.seat) return null;
return (
<span className="ms-auto flex min-w-0 items-center gap-2 text-content-muted">
{carrier && (
<bdi title={carrier} className="truncate">
{carrier}
</bdi>
)}
{leg.seat && <span className="flex-none">{t('assistant.card.booking.seat', { seat: leg.seat })}</span>}
</span>
);
}
/** A stopover booking leg by leg, the time between two legs as a quiet pill. */
function LegRows({ body }: { body: BookingTicket }) {
const { t } = useTranslation();
const format = useBookingFormat();
const layovers = layoversOf(body);
if (layovers.length === 0) return null;
return (
<ol className="m-0 mt-2 flex list-none flex-col gap-1 p-0">
{body.legs.map((leg, i) => {
const from = body.stops[i];
const to = body.stops[i + 1];
const stop = layovers[i];
return (
<li key={i} className="flex flex-col gap-1">
<span className="flex flex-wrap items-center gap-x-2 text-caption tabular-nums text-content-secondary">
<span className="font-semibold">{from?.code ?? from?.name}</span>
<span>{format.clock(leg.departs)}</span>
<RangeArrow />
<span className="font-semibold">{to?.code ?? to?.name}</span>
<span>{format.clock(leg.arrives)}</span>
<LegCarrier leg={leg} />
</span>
{stop && (
<span className="self-center rounded-full bg-surface-secondary px-2 text-caption text-content-muted">
{stop.minutes
? t('assistant.card.booking.layoverAt', { duration: format.minutes(stop.minutes), place: stop.place })
: stop.place}
</span>
)}
</li>
);
})}
</ol>
);
}
interface TicketFactsProps {
item: BookingItem;
body: BookingTicket;
card: Card;
later: number | null;
}
/** The facts under a ticket: its times without a route, its day, carrier, seat, platform and stops. */
function TicketFacts({ item, body, card, later }: TicketFactsProps) {
const { t } = useTranslation();
const first = body.legs[0] as BookingLeg;
const single = body.legs.length === 1 ? first : null;
const stops = stopsOf(body);
const carrier = single ? [single.carrier, single.number].filter(Boolean).join(' ') : '';
return (
<CardFacts>
{body.stops.length < 2 && <TimesPill leg={first} later={later} />}
<DayPills day={first.departsDay} changed={isChanged(card, item, 'day') || isChanged(card, item, 'time')} />
{carrier && (
<Pill icon={Ticket}>
<bdi className="truncate">{carrier}</bdi>
</Pill>
)}
{single?.seat && <SoftPill>{t('assistant.card.booking.seat', { seat: single.seat })}</SoftPill>}
{single?.platform && <SoftPill>{t('assistant.card.booking.platform', { platform: single.platform })}</SoftPill>}
{stops !== null && (
<Pill icon={Route} changed={isChanged(card, item, 'route')}>
{stops === 0 ? t('assistant.card.booking.direct') : t('assistant.card.booking.stops', { count: stops })}
</Pill>
)}
</CardFacts>
);
}
/** A flight, a train, a hire car: its route with times and codes, its legs, then its facts. */
export function TicketBody({ item, body, card }: { item: BookingItem; body: BookingTicket; card: Card }) {
const first = body.legs[0] as BookingLeg;
const last = body.legs[body.legs.length - 1] as BookingLeg;
const later = daysLater(first.departsDay, last.arrivesDay);
return (
<>
{body.stops.length >= 2 && <RouteLine item={item} body={body} later={later} />}
<LegRows body={body} />
<TicketFacts item={item} body={body} card={card} later={later} />
</>
);
}
/** Where a venue is, on two lines at most, the whole text in its tooltip. */
function PlaceLine({ text, changed }: { text: string | null; changed: boolean }) {
if (!text) return null;
return (
<p className="m-0 flex items-start gap-1 text-caption text-content-muted">
<MapPin size={11} aria-hidden="true" className="mt-0.5 flex-none" />
<bdi title={text} className="line-clamp-2">
{text}
</bdi>
{changed && <ChangedDot />}
</p>
);
}
/** The nights of a stay as a range of days, the trip's day numbers in its tooltip. */
function StayRange({ item, body, card }: { item: BookingItem; body: BookingStay; card: Card }) {
const { t } = useTranslation();
const format = useBookingFormat();
const start = body.checkIn ? body.checkIn.day : null;
const end = body.checkOut ? body.checkOut.day : null;
const from = format.day(start);
const to = format.day(end);
if (!from && !to) return null;
const range = from && to ? t('assistant.card.booking.range', { from, to }) : from || to;
const numbers = [format.dayNumber(start), format.dayNumber(end)].filter(Boolean).join(' → ');
return (
<Tooltip label={numbers || range}>
<span className="inline-flex">
<Pill icon={CalendarDays} changed={isChanged(card, item, 'stay')} label={range}>
<bdi className="truncate">{from}</bdi>
{from && to && <RangeArrow />}
<bdi className="truncate">{to}</bdi>
</Pill>
</span>
</Tooltip>
);
}
/** Check-in from (to the end of its window) in green, check-out by in red, as the planner's day heads. */
function StayTimes({ item, body, card }: { item: BookingItem; body: BookingStay; card: Card }) {
const { t } = useTranslation();
const format = useBookingFormat();
const checkIn = body.checkIn && body.checkIn.from ? body.checkIn : null;
const checkOut = body.checkOut && body.checkOut.by ? body.checkOut.by : null;
return (
<>
{checkIn && (
<Pill icon={LogIn} tone="success" changed={isChanged(card, item, 'checkIn')}>
{t('assistant.card.booking.checkInAt', { time: format.clock(checkIn.from) })}
{checkIn.until && <RangeArrow />}
{checkIn.until && <span>{format.clock(checkIn.until)}</span>}
</Pill>
)}
{checkOut && (
<Pill icon={LogOut} tone="danger" changed={isChanged(card, item, 'checkOut')}>
{t('assistant.card.booking.checkOutAt', { time: format.clock(checkOut) })}
</Pill>
)}
</>
);
}
/** A hotel or any stay: its nights, the check-in window and check-out (its address sits in the head). */
export function StayBody({ item, body, card }: { item: BookingItem; body: BookingStay; card: Card }) {
const { t } = useTranslation();
return (
<>
<CardFacts>
<StayRange item={item} body={body} card={card} />
{body.nights !== null && body.nights > 0 && (
<Pill icon={Moon}>{t('assistant.card.booking.nights', { count: body.nights })}</Pill>
)}
<StayTimes item={item} body={body} card={card} />
</CardFacts>
</>
);
}
/** A table, a show, a tour: where it is, its day and its time. */
export function VenueBody({ item, body, card }: { item: BookingItem; body: BookingVenue; card: Card }) {
const format = useBookingFormat();
return (
<>
<PlaceLine text={body.location} changed={isChanged(card, item, 'location')} />
<CardFacts>
<DayPills day={body.day} changed={isChanged(card, item, 'day')} />
{body.starts && (
<Pill icon={Clock} changed={isChanged(card, item, 'time')}>
<span>{format.clock(body.starts)}</span>
{body.ends && <RangeArrow />}
{body.ends && <span>{format.clock(body.ends)}</span>}
</Pill>
)}
</CardFacts>
</>
);
}
@@ -0,0 +1,174 @@
import type { AssistantApplied, BookingCard, BookingItem, BookingTicket } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { daysLater, isChanged, itemsOf, layoversOf, stopsOf, toneOfItem } from './bookingCardModel';
import { BOOKING_LOOK, bookingSpotOf, bookingTarget } from './bookingLook';
/**
* FE-AI-BOOKLOOK-001 to FE-AI-BOOKLOOK-008
*
* The pure side of the booking cards: what an applied booking or stay opens,
* where it sits on the map, which entity a card is about, and the ticket
* arithmetic (stops, stopovers, a later arrival) the card draws from.
*/
const day = (id: number | null, number: number | null, date: string | null) => ({ id, number, date });
const TICKET: BookingTicket = {
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA', lat: 50, lng: 8 },
{ name: 'Munich', code: 'MUC', lat: 48, lng: 11 },
{ name: 'New York', code: 'JFK', lat: 40, lng: -73 },
],
legs: [
{
carrier: null,
number: 'LH 101',
departs: '23:10',
departsDay: day(31, 1, '2026-06-03'),
arrives: '23:55',
arrivesDay: day(31, 1, '2026-06-03'),
minutes: 45,
seat: null,
platform: null,
},
{
carrier: null,
number: 'LH 410',
departs: '01:05',
departsDay: day(32, 2, '2026-06-04'),
arrives: '05:00',
arrivesDay: day(32, 2, '2026-06-04'),
minutes: null,
seat: null,
platform: null,
},
],
minutes: null,
mappable: true,
};
const BASE: Omit<BookingItem, 'body'> = {
tripId: 4,
tripTitle: null,
reservationId: 501,
type: 'flight',
title: 'Frankfurt to New York',
status: 'pending',
needsReview: false,
hasReference: false,
price: null,
travelers: [],
moreTravelers: 0,
changed: ['time'],
};
const FLIGHT: BookingItem = { ...BASE, body: TICKET };
const STAY: BookingItem = {
...BASE,
reservationId: null,
type: 'hotel',
body: {
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: null,
lat: null,
lng: null,
photoKey: null,
checkIn: { day: day(31, 1, '2026-06-03'), from: null, until: null },
checkOut: null,
nights: null,
},
};
const card = (items: BookingItem[], change: BookingCard['change'] = 'none'): BookingCard => ({
kind: 'booking',
change,
items,
more: 0,
});
const applied = (entity: AssistantApplied['entity'], ids: number[]): AssistantApplied => ({
toolCallId: 'tc-1',
entity,
ids,
dayId: 33,
undoUntil: null,
});
describe('booking look', () => {
it('FE-AI-BOOKLOOK-001: an applied booking opens in its tab; one of another trip opens that trip', () => {
const open = BOOKING_LOOK.entities.reservation.open;
expect(open(applied('reservation', [501]), card([FLIGHT]))).toEqual({
kind: 'tab',
tab: 'transports',
params: { booking: '501' },
});
expect(bookingTarget({ ...FLIGHT, type: 'restaurant', tripId: 9, tripTitle: 'Vienna' }, 7)).toEqual({
kind: 'page',
path: '/trips/9?tab=buchungen&booking=7',
});
expect(open(applied('reservation', [501]), null)).toBeNull();
});
it('FE-AI-BOOKLOOK-002: on the map a ticket shows its day, a stay its place, no card the day', () => {
const onMap = BOOKING_LOOK.entities.reservation.onMap;
expect(onMap(applied('reservation', [501]), card([FLIGHT]))).toEqual({ placeId: null, dayId: 31 });
expect(onMap(applied('reservation', [502]), card([STAY]))).toEqual({ placeId: 12, dayId: null });
expect(onMap(applied('reservation', [502]), null)).toEqual({ placeId: null, dayId: 33 });
expect(bookingSpotOf({ ...FLIGHT, tripTitle: 'Vienna' })).toBeNull();
});
it('FE-AI-BOOKLOOK-003: an applied stay shows its place and opens on its check-in day', () => {
const look = BOOKING_LOOK.entities.accommodation;
expect(look.onMap(applied('accommodation', [601]), card([STAY]))).toEqual({ placeId: 12, dayId: null });
expect(look.open(applied('accommodation', [601]), card([STAY]))).toEqual({
kind: 'map',
placeId: null,
dayId: 31,
});
expect(look.open(applied('accommodation', [601]), null)).toEqual({ kind: 'map', placeId: null, dayId: 33 });
});
it('FE-AI-BOOKLOOK-004: a lone stay is a stay, anything else the booking; confirming needs edit rights', () => {
const look = BOOKING_LOOK.cards.booking;
expect(look.entity(card([STAY]))).toBe('accommodation');
expect(look.entity(card([{ ...STAY, reservationId: 502 }]))).toBe('reservation');
expect(look.entity(card([FLIGHT]))).toBe('reservation');
expect(look.followUps.find((followUp) => followUp.id === 'bookingsConfirm')?.edits).toBe(true);
});
it('FE-AI-BOOKLOOK-005: a ticket counts stops, stopovers across midnight and a later arrival', () => {
expect(stopsOf(TICKET)).toBe(1);
expect(stopsOf({ ...TICKET, stops: [], legs: TICKET.legs.slice(0, 1) })).toBeNull();
expect(layoversOf(TICKET)).toEqual([{ place: 'MUC', minutes: 70 }]);
expect(layoversOf({ ...TICKET, stops: TICKET.stops.slice(0, 2) })).toEqual([]);
expect(daysLater(day(31, 1, '2026-06-03'), day(32, 2, '2026-06-04'))).toBe(1);
expect(daysLater(day(null, 1, null), day(null, 3, null))).toBe(2);
expect(daysLater(day(31, 1, '2026-06-03'), day(31, 1, '2026-06-03'))).toBeNull();
});
it('FE-AI-BOOKLOOK-006: only an update marks what it changes', () => {
expect(isChanged(card([FLIGHT], 'update'), FLIGHT, 'time')).toBe(true);
expect(isChanged(card([FLIGHT], 'update'), FLIGHT, 'status')).toBe(false);
expect(isChanged(card([FLIGHT], 'create'), FLIGHT, 'time')).toBe(false);
});
it("FE-AI-BOOKLOOK-007: an answer's booking cards read as one list with what they left out counted", () => {
const entries = [
{ toolCallId: 'a', card: { ...card([FLIGHT]), more: 2 } },
{ toolCallId: 'b', card: card([STAY]) },
];
expect(itemsOf(entries)).toEqual({ items: [FLIGHT, STAY], more: 2 });
});
it("FE-AI-BOOKLOOK-008: the dot takes the planner's tones: transit info, confirmed green, the rest amber", () => {
expect(toneOfItem({ type: 'transit', status: null })).toBe('transit');
expect(toneOfItem({ type: 'flight', status: 'confirmed' })).toBe('confirmed');
expect(toneOfItem({ type: 'flight', status: 'pending' })).toBe('pending');
expect(toneOfItem({ type: 'restaurant', status: 'cancelled' })).toBe('pending');
expect(toneOfItem({ type: 'hotel', status: null })).toBeNull();
});
});
@@ -1,23 +1,124 @@
import { Ticket } from 'lucide-react';
import { isTransportReservationType, type AssistantApplied, type BookingItem, type TrekCard } from '@trek/shared';
import { BedDouble, CalendarCog, Hotel, Ticket } from 'lucide-react';
import type { PackLook } from '../cardTypes';
import type { EntityTarget, PackLook } from '../cardTypes';
/**
* What the bookings bring to the chat's pure look: the verbs, tool names and
* steps they add, what an applied change of theirs opens, and the look of each
* of their kinds. Empty but for the booking entity until the booking cards fill
* it. A look stays pure, icons and literals only: the chat's main chunk reads
* every look, so whatever a look imports ships with it.
* What the bookings bring to the chat's pure look: the verbs and tool names
* of their changes, the reading steps, what an applied booking or stay opens
* (the booking in its tab, a stay on its check-in day) and the follow-ups of
* a booking answer. A look stays pure, icons and literals only: the chat's
* main chunk reads every look, so whatever a look imports ships with it.
*/
/** The first booking a card holds; an applied change's card holds one. */
function bookingItemOf(card: TrekCard | null): BookingItem | null {
return card?.kind === 'booking' ? (card.items[0] ?? null) : null;
}
/** The tab a booking lives in: the transports for anything travelled on, else the bookings. */
export function bookingTabOf(type: string): 'transports' | 'buchungen' {
return isTransportReservationType(type) ? 'transports' : 'buchungen';
}
/**
* Where "Open" takes a booking: its tab with the booking opened, on this
* trip; a booking of another trip (the upcoming list spans every trip) opens
* that trip's planner. `map` shows its route on the plan instead.
*/
export function bookingTarget(item: BookingItem, reservationId: number, map = false): EntityTarget {
const params = map ? { booking: String(reservationId), map: '1' } : { booking: String(reservationId) };
const tab = map ? 'plan' : bookingTabOf(item.type);
if (item.tripTitle !== null) {
return { kind: 'page', path: `/trips/${item.tripId}?${new URLSearchParams({ tab, ...params }).toString()}` };
}
return { kind: 'tab', tab, params };
}
/** The spot a booking has on the plan's map: a stay's or a venue's place, else the day it starts. */
export function bookingSpotOf(item: BookingItem | null): { placeId: number | null; dayId: number | null } | null {
if (!item || item.tripTitle !== null) return null;
const body = item.body;
if (body.shape === 'stay') {
if (body.placeId !== null) return { placeId: body.placeId, dayId: null };
return body.checkIn?.day.id ? { placeId: null, dayId: body.checkIn.day.id } : null;
}
if (body.shape === 'venue') {
if (body.placeId !== null) return { placeId: body.placeId, dayId: null };
return body.day?.id ? { placeId: null, dayId: body.day.id } : null;
}
const dayId = body.legs[0]?.departsDay?.id ?? null;
return dayId !== null ? { placeId: null, dayId } : null;
}
function reservationOnMap(applied: AssistantApplied, card: TrekCard | null) {
return bookingSpotOf(bookingItemOf(card)) ?? { placeId: null, dayId: applied.dayId };
}
function reservationOpen(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null {
const item = bookingItemOf(card);
const id = applied.ids[0] ?? item?.reservationId ?? null;
return item && id !== null ? bookingTarget(item, id) : null;
}
/** The check-in day of an applied stay, where the planner's day head shows it. */
function stayDayOf(applied: AssistantApplied, card: TrekCard | null): number | null {
const body = bookingItemOf(card)?.body;
return (body?.shape === 'stay' ? body.checkIn?.day.id : null) ?? applied.dayId;
}
export const BOOKING_LOOK = {
verbs: {},
tools: {},
steps: {},
verbs: {
addStay: { Icon: BedDouble, tone: 'success', labelKey: 'assistant.change.verb.addStay' },
updateStay: { Icon: CalendarCog, tone: 'info', labelKey: 'assistant.change.verb.updateStay' },
updateReservation: { Icon: Ticket, tone: 'info', labelKey: 'assistant.change.verb.updateReservation' },
},
tools: {
set_reservation_travelers: { labelKey: 'assistant.tool.set_reservation_travelers' },
link_hotel_accommodation: { labelKey: 'assistant.tool.link_hotel_accommodation' },
update_transport: { labelKey: 'assistant.tool.update_transport' },
delete_transport: { labelKey: 'assistant.tool.delete_transport' },
delete_accommodation: { labelKey: 'assistant.tool.delete_accommodation' },
reorder_reservations: { labelKey: 'assistant.tool.reorder_reservations' },
import_airtrail_flights: { labelKey: 'assistant.tool.import_airtrail_flights' },
},
steps: {
list_reservations: { stepKey: 'assistant.step.bookings' },
list_accommodations: { stepKey: 'assistant.step.stays' },
list_upcoming_reservations: { stepKey: 'assistant.step.upcoming' },
},
entities: {
// An applied booking shows on the map by its day, as before.
reservation: { Icon: Ticket, onMap: (applied) => ({ placeId: null, dayId: applied.dayId }) },
reservation: {
Icon: Ticket,
openLabelKey: 'assistant.card.booking.open',
onMap: reservationOnMap,
open: reservationOpen,
},
accommodation: {
Icon: Hotel,
openLabelKey: 'assistant.card.booking.openStay',
onMap: (applied, card) => {
const spot = bookingSpotOf(bookingItemOf(card));
return spot ?? { placeId: null, dayId: applied.dayId };
},
open: (applied, card) => {
const dayId = stayDayOf(applied, card);
return dayId !== null ? { kind: 'map', placeId: null, dayId } : null;
},
},
},
cards: {
booking: {},
booking: {
// A stay no booking links is a stay; anything else is the booking.
entity: (card) => {
const item = bookingItemOf(card);
return item?.body.shape === 'stay' && item.reservationId === null ? 'accommodation' : 'reservation';
},
followUps: [
{ id: 'bookingsOpen', labelKey: 'assistant.followUp.bookingsOpen' },
{ id: 'bookingsNights', labelKey: 'assistant.followUp.bookingsNights' },
{ id: 'bookingsConfirm', labelKey: 'assistant.followUp.bookingsConfirm', edits: true },
],
},
},
} satisfies PackLook<'booking'>;
@@ -1,12 +1,20 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
import type { CardView } from '../cardTypes';
import BookingBlock from './BookingBlock';
import { BookingChangeBody, BookingChangeFacts } from './BookingChangeSlots';
/**
* How the kinds of the bookings draw in the chat; each one draws nothing until
* its cards are built. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* How the bookings draw in the chat: an answer's bookings as one block, and
* a change's booking as a card under its row, which names it in place of the
* row's subject. This file only maps the kind to its view: the components
* live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const BOOKING_VIEWS = {
booking: PENDING_VIEW,
booking: {
Block: BookingBlock,
Body: BookingChangeBody,
Facts: BookingChangeFacts,
ownsSubject: true,
},
} satisfies { [K in 'booking']: CardView<K> };
@@ -0,0 +1,31 @@
import type { CardDay, CardMoney } from '@trek/shared';
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { useCardFormat } from '../parts/useCardFormat';
/**
* How a booking card writes its days, clocks, durations and prices: the
* user's language and 12 or 24 hour setting, as the planner's booking cards
* do. A day reads as its date, a trip planned without dates as "Day 3".
*/
export function useBookingFormat() {
const { t } = useTranslation();
const format = useCardFormat();
return useMemo(
() => ({
/** "Wed 3 Jun", or "Day 3" without a date; empty for no day. */
day: (day: CardDay | null): string => {
if (day?.date) return format.date(day.date);
return day?.number != null ? t('dayplan.dayN', { n: day.number }) : '';
},
/** "Day 3" beside a date, only when the trip numbers its days and the date is shown too. */
dayNumber: (day: CardDay | null): string =>
day?.date && day.number != null ? t('dayplan.dayN', { n: day.number }) : '',
clock: (value: string | null): string => (value ? format.clock(value) : ''),
minutes: (value: number | null): string => (value ? format.minutes(value) : ''),
money: (value: CardMoney): string => format.money(value),
}),
[format, t]
);
}
@@ -1,9 +1,19 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { type RefObject, useEffect, useState } from 'react';
import { fetchPhoto, getCached } from '../../services/photoService';
import { useAuthStore } from '../../store/authStore';
/**
* What a picture is asked for by: a found place, or a stay a booking card
* shows (its Google place id, its position and its name).
*/
interface PhotoPlace {
photoKey: string | null;
lat: number | null;
lng: number | null;
name: string;
}
/** The only pictures a found place shows: TREK's own photo proxy, never an address from the search. */
const PROXY_PREFIX = '/api/maps/place-photo/';
@@ -13,7 +23,7 @@ const PROXY_PREFIX = '/api/maps/place-photo/';
* a picture of its surroundings, the same street scene for every restaurant
* in a district; such a card shows its category instead.
*/
function photoKeyOf(place: AssistantFoundPlace): string | null {
function photoKeyOf(place: PhotoPlace): string | null {
return place.photoKey && !place.photoKey.startsWith('coords:') ? place.photoKey : null;
}
@@ -31,7 +41,7 @@ function proxiedPhoto(url: string | null | undefined): string | null {
* are off, for a place known only by its position, and for any answer that is
* not the proxy's own address.
*/
export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<Element | null>): string | null {
export function useFoundPlacePhoto(place: PhotoPlace, ref: RefObject<Element | null>): string | null {
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const key = photoKeyOf(place);
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
@@ -56,7 +66,7 @@ export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<El
useEffect(() => {
if (!visible || !enabled || !key) return;
let cancelled = false;
fetchPhoto(key, key, place.lat, place.lng, place.name, (entry) => {
fetchPhoto(key, key, place.lat ?? undefined, place.lng ?? undefined, place.name, (entry) => {
if (!cancelled) setSrc(proxiedPhoto(entry.photoUrl));
});
return () => {
+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]);
}
@@ -12,6 +12,9 @@ import { runsOnDay } from '../../../../utils/reservationRoutes'
import { filesFor } from '../../../../utils/reservationFiles'
import { bookingDayLabel, segmentCodeLabel } from '../../../../components/Planner/transportDetailModel'
import type { Reservation } from '../../../../types'
import { useSearchParams } from 'react-router'
import { isTransportReservationType } from '@trek/shared'
import { useBookingLink } from '../../../../hooks/useBookingLink'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
interface TransportSheetPayload {
@@ -59,6 +62,40 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const payload = (shell.sheet?.payload ?? {}) as TransportSheetPayload
const liveRes = planner.reservations.find(r => r.id === payload.reservationId) ?? null
// The map with a booking's route drawn on it: the route switched on, the sheet
// closed, the plan tab and the map shown. The map draws the day on the chips, and
// the Transports tab lists the whole trip, so the booking may run on a different
// day. Moved last, because entering the map picks the day the shell was already
// holding. A day inside the booking's span, the all-days view and a booking bound
// to no day are left as they are.
const revealOnMap = (r: Reservation) => {
if (!planner.visibleConnections.includes(r.id)) planner.toggleConnection(r.id)
shell.closeSheet()
if (shell.trTab !== 'plan') shell.setTrTab('plan')
if (shell.view !== 'map') shell.toggleView()
const bookingDayId = r.day_id ?? r.end_day_id
if (planner.selectedDayId != null && bookingDayId != null && !runsOnDay(r, planner.selectedDayId, planner.days)) {
planner.handleSelectDay(bookingDayId, false)
planner.setExpandedDayIds(new Set([bookingDayId]))
}
}
// A transport the chat points at (?booking=) opens here, or with &map=1 its route on
// the map; any other booking stays on its tab.
const [searchParams, setSearchParams] = useSearchParams()
useBookingLink({
searchParams,
setSearchParams,
tripId: planner.tripId,
reservations: planner.reservations,
loaded: !planner.isLoading && planner.trip?.id === planner.tripId,
take: (booking, toMap) => {
if (!isTransportReservationType(booking.type)) return
if (toMap) revealOnMap(booking)
else shell.openSheet('transport', { reservationId: booking.id })
},
})
const canEditDays = planner.can('day_edit', planner.trip)
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
@@ -117,19 +154,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
planner.toggleConnection(res.id)
return
}
if (!switchedOn) planner.toggleConnection(res.id)
shell.closeSheet()
if (shell.trTab !== 'plan') shell.setTrTab('plan')
if (shell.view !== 'map') shell.toggleView()
// The map draws the day on the chips, and the Transports tab lists the whole trip, so
// the booking may run on a different day. Moved last, because entering the map picks
// the day the shell was already holding. A day inside the booking's span, the
// all-days view and a booking bound to no day are left as they are.
const bookingDayId = res.day_id ?? res.end_day_id
if (planner.selectedDayId != null && bookingDayId != null && !runsOnDay(res, planner.selectedDayId, planner.days)) {
planner.handleSelectDay(bookingDayId, false)
planner.setExpandedDayIds(new Set([bookingDayId]))
}
revealOnMap(res)
}
const editTransport = () => {
@@ -0,0 +1,62 @@
import { renderHook } from '@testing-library/react';
import type { Reservation } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import type { DeepLinkProps } from './deepLinkTypes';
import { useBookingDeepLink } from './useBookingDeepLink';
/**
* FE-PLANNER-BOOKLINK-001 to FE-PLANNER-BOOKLINK-003
*
* The desktop planner's way into a booking the chat points at: its detail, or
* its route drawn on the plan, a route already drawn left as it is and its
* departure day picked.
*/
const FLIGHT = { id: 7, trip_id: 5, type: 'flight', day_id: 31 } as Reservation;
function setup(query: string, onMap = false) {
const searchParams = new URLSearchParams(query);
const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams));
const planner = {
tripId: 5,
reservations: [FLIGHT],
isLoading: false,
trip: { id: 5 },
openBookingDetail: vi.fn(),
showBookingOnMap: vi.fn(),
isBookingOnMap: vi.fn(() => onMap),
handleSelectDay: vi.fn(),
};
renderHook(() =>
useBookingDeepLink({
planner: planner as unknown as DeepLinkProps['planner'],
workspace: {} as DeepLinkProps['workspace'],
searchParams,
setSearchParams: setSearchParams as never,
})
);
return { planner, searchParams };
}
describe('useBookingDeepLink', () => {
it('FE-PLANNER-BOOKLINK-001: ?booking= opens the booking detail and leaves the URL', () => {
const { planner, searchParams } = setup('booking=7');
expect(planner.openBookingDetail).toHaveBeenCalledWith(FLIGHT);
expect(planner.showBookingOnMap).not.toHaveBeenCalled();
expect(searchParams.has('booking')).toBe(false);
});
it('FE-PLANNER-BOOKLINK-002: with map=1 the route is drawn on the plan', () => {
const { planner } = setup('booking=7&map=1');
expect(planner.showBookingOnMap).toHaveBeenCalledWith(FLIGHT);
expect(planner.openBookingDetail).not.toHaveBeenCalled();
});
it('FE-PLANNER-BOOKLINK-003: a route already drawn is not switched off again; the plan goes to its day', () => {
const { planner, searchParams } = setup('booking=7&map=1', true);
expect(planner.showBookingOnMap).not.toHaveBeenCalled();
expect(planner.handleSelectDay).toHaveBeenCalledWith(31);
expect(searchParams.has('map')).toBe(false);
});
});
@@ -1,9 +1,27 @@
import { useBookingLink } from '../../../hooks/useBookingLink';
import type { DeepLinkProps } from './deepLinkTypes';
/**
* Where the bookings open a booking the chat points at. Nothing yet: the booking cards
* fill it in, and until then the planner keeps its own links.
* A booking the chat points at, on the desktop planner: its detail opens, the
* same view a click on it in the plan or in its tab opens; with `&map=1` its
* route is drawn on the plan instead. A route already shown stays shown, and
* the plan goes to the day it departs.
*/
export function useBookingDeepLink(_links: DeepLinkProps): void {
// Filled in by the booking cards.
export function useBookingDeepLink({ planner, searchParams, setSearchParams }: DeepLinkProps): void {
const { tripId, reservations, isLoading, trip, openBookingDetail, showBookingOnMap, isBookingOnMap } = planner;
useBookingLink({
searchParams,
setSearchParams,
tripId,
reservations,
loaded: !isLoading && trip?.id === tripId,
take: (booking, onMap) => {
if (!onMap) openBookingDetail(booking);
else if (!isBookingOnMap(booking)) showBookingOnMap(booking);
else {
const dayId = booking.day_id ?? booking.end_day_id ?? null;
if (dayId !== null) planner.handleSelectDay(dayId);
}
},
});
}
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TRSH-001 to FE-MOB-TRSH-030
// FE-MOB-TRSH-001 to FE-MOB-TRSH-033
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -407,4 +407,39 @@ describe('MTransportSheet', () => {
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
})
describe('a booking the chat points at', () => {
it('FE-MOB-TRSH-031: ?booking= of a transport opens its sheet once and leaves the URL', () => {
const shell = makeShell({ sheet: null })
render(<MTransportSheet planner={makePlanner()} shell={shell} />, { initialEntries: ['/trips/1?booking=7'] })
expect(shell.openSheet).toHaveBeenCalledTimes(1)
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 7 })
})
it('FE-MOB-TRSH-032: a booking that is no transport, or an id the trip does not hold, opens nothing', () => {
const dinner = { ...FLIGHT, id: 8, type: 'restaurant', endpoints: [] } as unknown as Reservation
const shell = makeShell({ sheet: null })
render(<MTransportSheet planner={makePlanner({ reservations: [FLIGHT, dinner] })} shell={shell} />, {
initialEntries: ['/trips/1?booking=8'],
})
render(<MTransportSheet planner={makePlanner()} shell={shell} />, { initialEntries: ['/trips/1?booking=99'] })
expect(shell.openSheet).not.toHaveBeenCalled()
})
it('FE-MOB-TRSH-033: with &map=1 the route is switched on and the map shown on its day, no sheet opened', () => {
const days = [
{ id: 3, trip_id: 1, day_number: 1, date: '2026-05-01' },
{ id: 5, trip_id: 1, day_number: 3, date: '2026-05-03' },
]
const planner = makePlanner({ days, selectedDayId: 5 })
const shell = makeShell({ sheet: null, trTab: 'transports', view: 'plan' })
render(<MTransportSheet planner={planner} shell={shell} />, { initialEntries: ['/trips/1?booking=7&map=1'] })
expect(shell.openSheet).not.toHaveBeenCalled()
expect(planner.toggleConnection).toHaveBeenCalledTimes(1)
expect(planner.toggleConnection).toHaveBeenCalledWith(7)
expect(shell.setTrTab).toHaveBeenCalledWith('plan')
expect(shell.toggleView).toHaveBeenCalledTimes(1)
expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
})
})
})
@@ -7,13 +7,19 @@ import {
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
ok,
} from '../../nest-mcp';
import { UnitOfWork } from '../database/unit-of-work';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { PlacesService } from '../places/places.service';
import { AccommodationsService } from './accommodations.service';
import { idSchema, placeWebsiteSchema } from '@trek/shared';
import {
idSchema,
listAccommodationsRequestSchema,
placeWebsiteSchema,
type ListAccommodationsRequest,
} from '@trek/shared';
import { z } from 'zod';
@@ -328,6 +334,19 @@ export class AccommodationsMcp {
return ok({ success: true, linkedReservationId, linkedReservationIds, removedAssignments: mirror.removed });
}
@Tool({
name: 'list_accommodations',
description:
'List where a trip sleeps, stay by stay: the place, its address and position, the check-in and check-out days with their times, and the booking linked to it, its reference included, as the trip-accommodations resource reads them.',
inputSchema: listAccommodationsRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' },
})
async listAccommodations({ tripId }: ListAccommodationsRequest, ctx: McpContext) {
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
return ok({ accommodations: await this.accommodations.listAccommodations(tripId) });
}
@ResourceTemplate({
name: 'trip-accommodations',
uriTemplate: 'trek://trips/{tripId}/accommodations',
@@ -0,0 +1,52 @@
import type { AnswerInput } from '../card-types';
import { recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingCard, bookingItemOf } from './booking-item';
import { idOf, rowOfReservation, rowOfStay, rowOfUpcoming, scopeOfCard, type BookingRow } from './booking-row';
import type { TrekCard } from '@trek/shared';
/**
* The cards of the booking reads, from the tool's raw answer: the first eight
* bookings in the order the answer lists them, the rest counted. The trip
* summary draws none: it runs on most turns and would repeat its card under
* every answer.
*/
const SHOWN = 8;
function listCard(input: AnswerInput, rows: Row[], toRow: (raw: Row) => BookingRow): TrekCard | null {
const scope = scopeOfCard(input.card);
return bookingCard('none', rows.slice(0, SHOWN).map((raw) => bookingItemOf(toRow(raw), scope)), rows.length);
}
/** A stay listed once: the list joins each booking that links it, so one stay can come twice. */
function distinctStays(rows: Row[]): Row[] {
const seen = new Set<number>();
return rows.filter((row) => {
const id = idOf(row.id);
if (id === null || seen.has(id)) return id === null;
seen.add(id);
return true;
});
}
function reservationsCard(input: AnswerInput): TrekCard | null {
const rows = rowsOf(recordOf(input.value).reservations);
return listCard(input, rows, (raw) => rowOfReservation(raw, input.card.tripId, input.card));
}
function staysCard(input: AnswerInput): TrekCard | null {
const rows = distinctStays(rowsOf(recordOf(input.value).accommodations));
return listCard(input, rows, (raw) => rowOfStay(raw, input.card.tripId, input.card));
}
function upcomingCard(input: AnswerInput): TrekCard | null {
const rows = rowsOf(recordOf(input.value).reservations);
return listCard(input, rows, (raw) => rowOfUpcoming(raw, input.card.tripId));
}
export const BOOKING_ANSWERS = {
list_reservations: reservationsCard,
list_accommodations: staysCard,
list_upcoming_reservations: upcomingCard,
};
@@ -0,0 +1,116 @@
import type { ToolArgs } from '../card-types';
import { recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingCard, bookingItemOf, travelersOf } from './booking-item';
import { idOf, rowOfReservation, rowOfStay, scopeOfPreview } from './booking-row';
import type { AssistantChangePreview, BookingBody, BookingItem, TrekCard } from '@trek/shared';
/**
* The cards of applied booking writes, in place of the preview card: the
* booking as it was saved (its real status, coordinates resolved, legs
* normalised, the stay it links). Decisions run before any trip card is read,
* so the days and the people come from what the preview card named; what the
* saved row does not carry (a stay's times, a photo) is kept from the preview.
*/
interface AppliedInput {
args: ToolArgs;
result: unknown;
preview: AssistantChangePreview | null;
}
function previewItem(preview: AssistantChangePreview | null): BookingItem | null {
const card = preview?.card;
return card?.kind === 'booking' ? (card.items[0] ?? null) : null;
}
type StayBody = Extract<BookingBody, { shape: 'stay' }>;
/** A saved stay, the times and the picture the saved row leaves out kept from the preview. */
function filledStay(saved: StayBody, shown: StayBody): StayBody {
const checkIn = saved.checkIn && {
...saved.checkIn,
from: saved.checkIn.from ?? shown.checkIn?.from ?? null,
until: saved.checkIn.until ?? shown.checkIn?.until ?? null,
};
return {
...saved,
address: saved.address ?? shown.address,
lat: saved.lat ?? shown.lat,
lng: saved.lng ?? shown.lng,
photoKey: saved.photoKey ?? shown.photoKey,
checkIn,
checkOut: saved.checkOut && { ...saved.checkOut, by: saved.checkOut.by ?? shown.checkOut?.by ?? null },
nights: saved.nights ?? shown.nights,
};
}
/** The saved body, its gaps filled from what the preview showed. */
function filled(saved: BookingBody, shown: BookingBody | undefined): BookingBody {
if (saved.shape === 'stay' && shown?.shape === 'stay') return filledStay(saved, shown);
if (saved.shape === 'venue' && shown?.shape === 'venue') {
return {
...saved,
location: saved.location ?? shown.location,
lat: saved.lat ?? shown.lat,
lng: saved.lng ?? shown.lng,
};
}
return saved;
}
function savedCard(shown: BookingItem | null, item: BookingItem | null): TrekCard | null {
if (!item) return null;
return bookingCard('none', [{ ...item, body: filled(item.body, shown?.body), changed: shown?.changed ?? [] }]);
}
function tripIdOf(shown: BookingItem | null, raw: Row): number | null {
return shown?.tripId ?? idOf(raw.trip_id);
}
/** A booking a write answered with (`{ reservation }`). */
export function appliedReservation({ result, preview }: AppliedInput): TrekCard | null {
const raw = recordOf(recordOf(result).reservation);
const shown = previewItem(preview);
const tripId = tripIdOf(shown, raw);
if (idOf(raw.id) === null || tripId === null) return null;
const scope = scopeOfPreview(shown ? [shown] : [], tripId);
return savedCard(shown, bookingItemOf(rowOfReservation(raw, tripId), scope));
}
/**
* A booking a file's import created. Its answer names the id alone (no route,
* times or stay dates), so the card is the preview's, now saved: the id it got
* and, with the id, the route it can show on the map.
*/
export function appliedImport({ result, preview }: AppliedInput): TrekCard | null {
const shown = previewItem(preview);
const id = idOf(recordOf(recordOf(result).reservation).id);
if (!shown || id === null) return null;
const body = shown.body;
const located = body.shape === 'ticket' ? body.stops.filter((s) => s.lat !== null && s.lng !== null).length : 0;
const saved = body.shape === 'ticket' ? { ...body, mappable: located >= 2 } : body;
return bookingCard('none', [{ ...shown, reservationId: id, changed: [], body: saved }]);
}
/** A stay a write answered with (`{ accommodation }`, with `place` when the write created it too). */
export function appliedStay({ result, preview }: AppliedInput): TrekCard | null {
const answer = recordOf(result);
const raw = recordOf(answer.accommodation);
const shown = previewItem(preview);
const tripId = tripIdOf(shown, raw);
if (idOf(raw.id) === null || tripId === null) return null;
const place = answer.place && typeof answer.place === 'object' ? recordOf(answer.place) : null;
const scope = scopeOfPreview(shown ? [shown] : [], tripId);
const item = bookingItemOf(rowOfStay(raw, tripId, null, place), scope);
// A stay row knows no booking; the preview's title and status stand where it named them.
return savedCard(shown, item && shown ? { ...item, title: shown.title, status: item.status ?? shown.status } : item);
}
/** The travellers a write set (`{ travelers }`) on the booking the preview showed. */
export function appliedTravelers({ result, preview }: AppliedInput): TrekCard | null {
const shown = previewItem(preview);
if (!shown) return null;
const { people, more } = travelersOf(rowsOf(recordOf(result).travelers));
return bookingCard('none', [{ ...shown, travelers: people, moreTravelers: more }]);
}
@@ -0,0 +1,291 @@
import type { PreviewContext, ToolArgs } from '../card-types';
import { argId, finite, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingCard, bookingItemOf, changedBetween } from './booking-item';
import { idOf, rowOfReservation, rowOfStay, scopeOfCard, type BookingRow, type StaySource } from './booking-row';
import { bookingRowOf, dateOf, stayRowOf } from './booking-trip';
import type { BookingCard, CardMoney, TrekCard } from '@trek/shared';
/**
* The cards of held booking writes: what will be saved, drawn from the
* call's arguments and the trip as this turn read it. A create shows the new
* booking, an update the booking as it will read afterwards with the fields
* that change marked, a removal the booking as it is. A write about a booking
* the trip card does not hold draws no card; the row of words stays.
*/
const CURRENCY = /^[A-Z]{3}$/;
/** A stay moved to another place: what the old place named is dropped, so the new one's shows. */
const UNPLACED = { name: null, address: null, lat: null, lng: null, photoKey: null } as const;
/** A booking with nothing known yet, for a create to fill from its arguments. */
function blankRow(tripId: number): BookingRow {
return {
tripId,
tripTitle: null,
id: null,
type: null,
title: null,
status: null,
needsReview: false,
confirmation: null,
reservationTime: null,
reservationEndTime: null,
dayId: null,
endDayId: null,
dayDate: null,
location: null,
placeId: null,
placeName: null,
assignmentId: null,
meta: {},
endpoints: [],
travelers: [],
stay: null,
};
}
function blankStay(): StaySource {
return {
accommodationId: null,
placeId: null,
startDayId: null,
endDayId: null,
startDate: null,
endDate: null,
checkIn: null,
checkInEnd: null,
checkOut: null,
name: null,
address: null,
lat: null,
lng: null,
photoKey: null,
hasReference: false,
};
}
/** A price argument in its currency (the trip's unless the tool takes one). */
function argPrice(amount: unknown, currency: unknown, ctx: PreviewContext): CardMoney | null {
const value = finite(amount);
const code = typeof currency === 'string' ? currency.toUpperCase() : ctx.card.currency;
if (value === null || value <= 0 || value > 1e9 || !code || !CURRENCY.test(code)) return null;
return { amount: value, currency: code };
}
/** The plain-string metadata a transport tool takes. */
function stringsOf(value: unknown): Row {
return Object.fromEntries(Object.entries(recordOf(value)).filter(([, inner]) => typeof inner === 'string'));
}
/** Endpoints as the tool will store them: a flight's code-only stop placed at its airport, as the tool does. */
function endpointsOf(ctx: PreviewContext, value: unknown): Row[] {
return rowsOf(value).map((ep) => {
if (finite(ep.lat) !== null && finite(ep.lng) !== null) return ep;
const airport = typeof ep.code === 'string' ? ctx.airport(ep.code) : null;
return airport ? { ...ep, lat: airport.lat, lng: airport.lng, timezone: ep.timezone ?? airport.tz } : ep;
});
}
/** A transport write's arguments over the booking they change (a blank one for a create). */
function withTransportArgs(ctx: PreviewContext, base: BookingRow, args: ToolArgs): BookingRow {
let meta = base.meta;
if (args.metadata !== undefined) {
// The tool replaces the metadata whole, but a price mirrored from an expense survives unless it is named.
const { price, priceCurrency } = base.meta;
meta = { ...(price !== undefined ? { price, priceCurrency } : {}), ...stringsOf(args.metadata) };
}
if (Array.isArray(args.legs)) meta = { ...meta, legs: rowsOf(args.legs) };
return {
...base,
type: args.type ?? base.type,
title: args.title ?? base.title,
status: args.status ?? base.status,
dayId: args.start_day_id ?? base.dayId,
endDayId: args.end_day_id ?? (base.id === null ? args.start_day_id : base.endDayId),
reservationTime: args.reservation_time ?? base.reservationTime,
reservationEndTime: args.reservation_end_time ?? base.reservationEndTime,
confirmation: args.confirmation_number ?? base.confirmation,
needsReview: typeof args.needs_review === 'boolean' ? args.needs_review : base.needsReview,
meta,
endpoints: args.endpoints !== undefined ? endpointsOf(ctx, args.endpoints) : base.endpoints,
...(args.price !== undefined ? { price: argPrice(args.price, null, ctx) } : {}),
};
}
/** A booking write's arguments over the booking they change; a hotel's place is its stay's. */
function withBookingArgs(base: BookingRow, args: ToolArgs): BookingRow {
const type = args.type ?? base.type;
const row: BookingRow = {
...base,
type,
title: args.title ?? base.title,
status: args.status ?? base.status,
reservationTime: args.reservation_time ?? base.reservationTime,
reservationEndTime: args.reservation_end_time ?? base.reservationEndTime,
location: args.location ?? base.location,
confirmation: args.confirmation_number ?? base.confirmation,
assignmentId: args.assignment_id !== undefined ? args.assignment_id : base.assignmentId,
// A hotel turned into another type no longer reads as a stay, whatever it still links.
stay: type === 'hotel' ? base.stay : null,
};
if (args.place_id === undefined) return row;
if (type !== 'hotel') return { ...row, placeId: args.place_id };
return { ...row, stay: { ...(base.stay ?? blankStay()), ...UNPLACED, placeId: idOf(args.place_id) } };
}
function created(ctx: PreviewContext, row: BookingRow): BookingCard | null {
return bookingCard('create', [bookingItemOf(row, scopeOfCard(ctx.card))]);
}
/** The update's card: the booking as it will read, every field whose card value changes marked. */
function updated(ctx: PreviewContext, before: BookingRow, after: BookingRow): BookingCard | null {
const scope = scopeOfCard(ctx.card);
const old = bookingItemOf(before, scope);
const next = bookingItemOf(after, scope);
if (!old || !next) return null;
return bookingCard('update', [{ ...next, changed: changedBetween(old, next) }]);
}
function currentBooking(ctx: PreviewContext, args: ToolArgs): BookingRow | null {
const raw = bookingRowOf(ctx.card, argId(args, 'reservationId'));
return raw ? rowOfReservation(raw, ctx.card.tripId, ctx.card) : null;
}
function currentStay(ctx: PreviewContext, args: ToolArgs): BookingRow | null {
const raw = stayRowOf(ctx.card, argId(args, 'accommodationId'));
return raw ? rowOfStay(raw, ctx.card.tripId, ctx.card) : null;
}
function removed(ctx: PreviewContext, row: BookingRow | null): BookingCard | null {
return row ? bookingCard('remove', [bookingItemOf(row, scopeOfCard(ctx.card))]) : null;
}
/** A stay from a stay tool's arguments: the place, the nights, the three times. */
function stayOfArgs(args: ToolArgs, base: StaySource = blankStay()): StaySource {
return {
...base,
placeId: argId(args, 'place_id') ?? base.placeId,
startDayId: argId(args, 'start_day_id') ?? base.startDayId,
endDayId: argId(args, 'end_day_id') ?? base.endDayId,
checkIn: args.check_in ?? base.checkIn,
checkInEnd: args.check_in_end ?? base.checkInEnd,
checkOut: args.check_out ?? base.checkOut,
hasReference: typeof args.confirmation === 'string' ? args.confirmation.trim() !== '' : base.hasReference,
};
}
type ChangeBuilder = (ctx: PreviewContext, args: ToolArgs) => TrekCard | null;
export const BOOKING_CHANGE_CARDS: Readonly<Record<string, ChangeBuilder>> = {
create_transport: (ctx, args) =>
created(ctx, withTransportArgs(ctx, { ...blankRow(ctx.card.tripId), status: 'pending' }, args)),
create_reservation: (ctx, args) => {
const hotel =
args.type === 'hotel' &&
argId(args, 'place_id') !== null &&
argId(args, 'start_day_id') !== null &&
argId(args, 'end_day_id') !== null;
const base: BookingRow = { ...blankRow(ctx.card.tripId), status: 'pending', type: args.type };
const row = withBookingArgs(base, { ...args, place_id: hotel ? undefined : args.place_id });
return created(ctx, {
...row,
dayId: args.day_id,
price: argPrice(args.price, null, ctx),
stay: hotel ? stayOfArgs({ ...args, confirmation: args.confirmation_number }) : null,
});
},
update_reservation: (ctx, args) => {
const before = currentBooking(ctx, args);
return before ? updated(ctx, before, withBookingArgs(before, args)) : null;
},
update_transport: (ctx, args) => {
const before = currentBooking(ctx, args);
return before ? updated(ctx, before, withTransportArgs(ctx, before, args)) : null;
},
delete_reservation: (ctx, args) => removed(ctx, currentBooking(ctx, args)),
delete_transport: (ctx, args) => removed(ctx, currentBooking(ctx, args)),
set_reservation_travelers: (ctx, args) => {
const before = currentBooking(ctx, args);
if (!before) return null;
const ids = Array.isArray(args.user_ids) ? args.user_ids : [];
// Somebody not on the trip is left out, as the tool leaves them out.
const travelers = ids.flatMap((id: unknown) => {
const person = typeof id === 'number' ? ctx.card.members.get(id) : undefined;
return person ? [{ user_id: id, username: person.name, avatar: person.avatarKey }] : [];
});
return updated(ctx, before, { ...before, travelers });
},
link_hotel_accommodation: (ctx, args) => {
const before = currentBooking(ctx, args);
if (!before) return null;
const stay = stayOfArgs(args, before.stay ?? blankStay());
return updated(ctx, before, { ...before, stay: { ...stay, ...UNPLACED } });
},
create_accommodation: (ctx, args) => {
const place = ctx.card.places.get(argId(args, 'place_id') ?? 0);
if (!place) return null;
return created(ctx, { ...blankRow(ctx.card.tripId), type: 'hotel', title: place.name, stay: stayOfArgs(args) });
},
create_place_accommodation: (ctx, args) =>
created(ctx, {
...blankRow(ctx.card.tripId),
type: 'hotel',
title: args.name,
price: argPrice(args.price, args.currency, ctx),
stay: {
...stayOfArgs({ ...args, place_id: undefined }),
name: args.name,
address: args.address,
lat: args.lat,
lng: args.lng,
photoKey: args.google_place_id,
},
}),
update_accommodation: (ctx, args) => {
const before = currentStay(ctx, args);
if (!before?.stay) return null;
const placeId = argId(args, 'place_id');
const moved = placeId !== null && placeId !== before.stay.placeId;
// Another place: its name, address and position come from the trip's place, not the old stay's.
const base = moved ? { ...before.stay, ...UNPLACED } : before.stay;
const title = moved ? (ctx.card.places.get(placeId)?.name ?? before.title) : before.title;
return updated(ctx, before, { ...before, title, stay: stayOfArgs(args, base) });
},
delete_accommodation: (ctx, args) => removed(ctx, currentStay(ctx, args)),
};
/** A booking read from an attached file (a BookingImportPreviewItem), as it will be saved. */
export function importedBookingCard(ctx: PreviewContext, args: ToolArgs): TrekCard | null {
const stay = recordOf(args._accommodation);
const venue = recordOf(args._venue);
const isStay = args.type === 'hotel' || args._accommodation !== undefined;
const row: BookingRow = {
...blankRow(ctx.card.tripId),
type: isStay ? 'hotel' : args.type,
title: args.title,
needsReview: args.needs_review === true,
confirmation: args.confirmation_number,
reservationTime: args.reservation_time,
reservationEndTime: args.reservation_end_time,
location: args.location ?? venue.name,
meta: recordOf(args.metadata),
endpoints: rowsOf(args.endpoints),
point: { lat: venue.lat, lng: venue.lng },
stay: isStay
? {
...blankStay(),
startDate: dateOf(stay.check_in ?? args.reservation_time),
endDate: dateOf(stay.check_out ?? args.reservation_end_time),
checkIn: stay.check_in,
checkOut: stay.check_out,
address: venue.address,
lat: venue.lat,
lng: venue.lng,
hasReference: typeof stay.confirmation === 'string' && stay.confirmation.trim() !== '',
}
: null,
};
return created(ctx, row);
}
@@ -0,0 +1,189 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, recordOf, type Row } from '../card-values';
import { clip, dayLabel, formatDateTime, memberNames, money, placeName, Rows, text } from '../preview-format';
import { rowsOf } from '../undo-snapshot';
import { bookingRowOf, stayRowOf } from './booking-trip';
import type { AssistantChangeRow } from '@trek/shared';
/**
* The words of the curated booking changes: the row a receipt, a share text
* or a thread stored before the cards still reads. The card draws the rest;
* the booking reference is left out here as on the card.
*/
type Describer = (ctx: PreviewContext, args: ToolArgs) => Described;
/** A date and time as the card writes it; a bare clock as it came. */
function when(ctx: PreviewContext, value: unknown): string {
if (typeof value !== 'string') return '';
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) ? formatDateTime(ctx, value) : text(value);
}
/** "Frankfurt (FRA) → New York (JFK)" from endpoints, empty without both ends. */
function routeText(endpoints: unknown): string {
const stops = rowsOf(endpoints);
const end = (role: string) => {
const found = stops.find((ep) => ep.role === role);
const name = text(found?.name);
const code = text(found?.code);
if (!name) return code;
return code && code !== name ? `${name} (${code})` : name;
};
const from = end('from');
const to = end('to');
return from && to ? clip(`${from} → ${to}`) : '';
}
/** A stay's nights in words: the place, check-in day to check-out day. */
function stayText(ctx: PreviewContext, placeId: number | null, start: number | null, end: number | null): string {
if (start === null || end === null) return '';
const place = placeName(ctx, placeId);
const range = `${dayLabel(ctx, start)} → ${dayLabel(ctx, end)}`;
return place ? `${place}, ${range}` : range;
}
function price(ctx: PreviewContext, amount: unknown, currency: unknown): string {
if (typeof amount !== 'number' || amount <= 0) return '';
return money(ctx, amount, typeof currency === 'string' ? currency : ctx.card.currency);
}
/** A status change, under the plain field name the generic card uses. */
function statusRow(rows: Rows, before: unknown, after: unknown): void {
if (after === undefined) return;
const row: AssistantChangeRow = {
op: 'change',
labelKey: 'field',
field: 'status',
before: text(before) || null,
after: text(after),
};
rows.rows.push(row);
}
/** The changes an update makes to a booking, each against what the trip holds now. */
function bookingChangeRows(ctx: PreviewContext, current: Row, args: ToolArgs): Rows {
const rows = new Rows();
if (args.title !== undefined) rows.change('name', text(current.title), text(args.title));
if (args.type !== undefined) rows.change('type', text(current.type), text(args.type));
statusRow(rows, current.status, args.status);
if (args.start_day_id !== undefined) {
rows.change('day', dayLabel(ctx, argId(current, 'day_id')), dayLabel(ctx, argId(args, 'start_day_id')));
}
if (args.reservation_time !== undefined) {
rows.change('time', when(ctx, current.reservation_time), when(ctx, args.reservation_time));
}
if (args.reservation_end_time !== undefined) {
rows.change('endTime', when(ctx, current.reservation_end_time), when(ctx, args.reservation_end_time));
}
if (args.endpoints !== undefined) rows.change('route', routeText(current.endpoints), routeText(args.endpoints));
if (args.location !== undefined) rows.change('location', text(current.location), text(args.location));
if (args.notes !== undefined) rows.change('notes', text(current.notes), text(args.notes));
return rows;
}
function updateBooking(tool: string): Describer {
return (ctx, args) => {
const current = recordOf(bookingRowOf(ctx.card, argId(args, 'reservationId')));
return {
subject: text(current.title) || text(args.title),
rows: bookingChangeRows(ctx, current, args),
dayId: argId(current, 'day_id'),
placeIds: [],
tool,
};
};
}
export const BOOKING_DESCRIBERS: Readonly<Record<string, Describer>> = {
create_transport: (ctx, args) => {
const dayId = argId(args, 'start_day_id');
const rows = new Rows()
.add('type', text(args.type))
.add('route', routeText(args.endpoints))
.add('day', dayLabel(ctx, dayId))
.add('time', when(ctx, args.reservation_time))
.add('endTime', when(ctx, args.reservation_end_time))
.add('price', price(ctx, args.price, null))
.add('notes', text(args.notes));
return { subject: text(args.title), rows, dayId, placeIds: [], tool: 'create_transport' };
},
update_reservation: updateBooking('update_reservation'),
update_transport: updateBooking('update_transport'),
set_reservation_travelers: (ctx, args) => {
const current = recordOf(bookingRowOf(ctx.card, argId(args, 'reservationId')));
const before = rowsOf(current.travelers).map((traveler) => traveler.user_id);
const rows = new Rows().change('members', memberNames(ctx, before), memberNames(ctx, args.user_ids));
return {
subject: text(current.title),
rows,
dayId: argId(current, 'day_id'),
placeIds: [],
tool: 'set_reservation_travelers',
};
},
link_hotel_accommodation: (ctx, args) => {
const current = recordOf(bookingRowOf(ctx.card, argId(args, 'reservationId')));
const placeId = argId(args, 'place_id');
const start = argId(args, 'start_day_id');
const rows = new Rows()
.add('stay', stayText(ctx, placeId, start, argId(args, 'end_day_id')))
.add('checkIn', text(args.check_in))
.add('checkOut', text(args.check_out));
return {
subject: text(current.title),
rows,
dayId: start,
placeIds: placeId ? [placeId] : [],
tool: 'link_hotel_accommodation',
};
},
create_accommodation: (ctx, args) => {
const placeId = argId(args, 'place_id');
const start = argId(args, 'start_day_id');
const rows = new Rows()
.add('stay', stayText(ctx, null, start, argId(args, 'end_day_id')))
.add('checkIn', text(args.check_in))
.add('checkOut', text(args.check_out))
.add('notes', text(args.notes));
return {
subject: placeName(ctx, placeId),
rows,
dayId: start,
placeIds: placeId ? [placeId] : [],
tool: 'create_accommodation',
};
},
create_place_accommodation: (ctx, args) => {
const start = argId(args, 'start_day_id');
const rows = new Rows()
.add('address', text(args.address))
.add('stay', stayText(ctx, null, start, argId(args, 'end_day_id')))
.add('checkIn', text(args.check_in))
.add('checkOut', text(args.check_out))
.add('price', price(ctx, args.price, args.currency))
.add('notes', text(args.accommodation_notes));
return { subject: text(args.name), rows, dayId: start, placeIds: [] };
},
update_accommodation: (ctx, args) => {
const current = recordOf(stayRowOf(ctx.card, argId(args, 'accommodationId')));
const placeId = argId(args, 'place_id') ?? argId(current, 'place_id');
const rows = new Rows();
if (args.place_id !== undefined) {
rows.change('stay', placeName(ctx, argId(current, 'place_id')), placeName(ctx, argId(args, 'place_id')));
}
if (args.start_day_id !== undefined || args.end_day_id !== undefined) {
const before = stayText(ctx, null, argId(current, 'start_day_id'), argId(current, 'end_day_id'));
const start = argId(args, 'start_day_id') ?? argId(current, 'start_day_id');
rows.change('day', before, stayText(ctx, null, start, argId(args, 'end_day_id') ?? argId(current, 'end_day_id')));
}
if (args.check_in !== undefined) rows.change('checkIn', text(current.check_in), text(args.check_in));
if (args.check_out !== undefined) rows.change('checkOut', text(current.check_out), text(args.check_out));
if (args.notes !== undefined) rows.change('notes', text(current.notes), text(args.notes));
return {
subject: text(current.place_name) || placeName(ctx, placeId),
rows,
dayId: argId(args, 'start_day_id') ?? argId(current, 'start_day_id'),
placeIds: placeId ? [placeId] : [],
};
},
};
@@ -0,0 +1,361 @@
import { resolveTimeZone, wallClockInstant } from '../../../common/timezoneService';
import { clipped, clockOf, fileKeyOf, finite, isLat, isLng, recordOf, type Row } from '../card-values';
import { idOf, legsHaveReference, textOf, type BookingRow, type BookingScope, type StaySource } from './booking-row';
import { dateOf } from './booking-trip';
import {
BOOKING_CHANGED_FIELDS,
cardFileKeySchema,
cardPhotoKeySchema,
isTransportReservationType,
LEG_RESERVATION_TYPES,
RESERVATION_STATUSES,
type BookingCard,
type BookingChangedField,
type BookingItem,
type BookingLeg,
type BookingStay,
type BookingStop,
type BookingTicket,
type BookingVenue,
type CardDay,
type CardMoney,
type CardPerson,
} from '@trek/shared';
/**
* One booking as its card shows it, from a `BookingRow` and its scope. The
* reference number is never copied, only that one exists; a price, a time or
* a coordinate that does not read cleanly is left out rather than guessed. A
* duration needs both ends' zones (stored, or from the coordinates): wall
* clocks alone across zones would lie.
*/
const MAX_ITEMS = 8;
const MAX_STOPS = 9;
const MAX_TRAVELERS = 8;
const LEG_MAX_MINUTES = 10_080;
const TRIP_MAX_MINUTES = 20_160;
const STOP_CODE = /^[A-Z0-9]{2,5}$/;
const TYPE_KEY = /^[a-z_]{1,30}$/;
const CURRENCY = /^[A-Z]{3}$/;
/** A stop with what the card does not show but needs for the times: its zone and its local clock. */
interface RouteStop {
stop: BookingStop;
zone: string | null;
localTime: unknown;
localDate: string | null;
}
function coord(value: unknown, isValid: (n: number | null) => n is number): number | null {
const n = finite(value);
return isValid(n) ? n : null;
}
function typeOf(row: BookingRow): string {
return typeof row.type === 'string' && TYPE_KEY.test(row.type) ? row.type : 'other';
}
function statusOf(row: BookingRow, type: string): BookingItem['status'] {
const status = RESERVATION_STATUSES.find((known) => known === row.status);
return status && type !== 'transit' ? status : null;
}
/** The stored price (`metadata.price`, text or number) in its currency, else the trip's. */
function priceOf(row: BookingRow, scope: BookingScope): CardMoney | null {
if (row.price !== undefined) return row.price;
const raw = row.meta.price;
const amount = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
if (!Number.isFinite(amount) || amount <= 0 || amount > 1e9) return null;
const own = row.meta.priceCurrency;
const currency = typeof own === 'string' && CURRENCY.test(own) ? own : scope.currency;
return currency && CURRENCY.test(currency) ? { amount, currency } : null;
}
/** The stored file name of an avatar; a picture on another host (a sign-in provider's) gives none. */
function avatarKeyOf(raw: unknown): string | null {
if (typeof raw === 'string' && cardFileKeySchema.safeParse(raw).success) return raw;
return fileKeyOf(raw);
}
export function travelersOf(rows: Row[]): { people: CardPerson[]; more: number } {
const people = rows.flatMap((row): CardPerson[] => {
const id = idOf(row.user_id);
const name = clipped(row.username, 80);
return id !== null && name !== null ? [{ id, name, avatarKey: avatarKeyOf(row.avatar) }] : [];
});
return { people: people.slice(0, MAX_TRAVELERS), more: Math.min(500, Math.max(0, people.length - MAX_TRAVELERS)) };
}
/** Whether the row has a confirmation of its own, on a leg or on its stay; never the value. */
function hasReference(row: BookingRow): boolean {
return textOf(row.confirmation) !== null || legsHaveReference(row.meta) || row.stay?.hasReference === true;
}
/** A day by id where the row belongs to this trip, else by its date. */
function dayFor(row: BookingRow, scope: BookingScope, id: unknown, date: string | null): CardDay | null {
if (row.tripId !== scope.tripId) return date ? { id: null, number: null, date } : null;
return scope.day(id) ?? scope.dayOn(date);
}
/** The minutes from one wall clock to another, each in its zone; null without both ends or past `max`. */
function minutesBetween(
from: { date: string | null; time: string | null; zone: string | null },
to: { date: string | null; time: string | null; zone: string | null },
max: number,
): number | null {
if (!from.date || !from.time || !from.zone || !to.date || !to.time || !to.zone) return null;
const start = wallClockInstant(from.date, from.time, from.zone);
const end = wallClockInstant(to.date, to.time, to.zone);
if (start === null || end === null) return null;
const minutes = Math.round((end - start) / 60_000);
return minutes > 0 && minutes <= max ? minutes : null;
}
function routeOf(endpoints: Row[]): RouteStop[] {
const sorted = [...endpoints].sort((a, b) => (finite(a.sequence) ?? 0) - (finite(b.sequence) ?? 0));
return sorted.flatMap((ep): RouteStop[] => {
const name = clipped(ep.name, 80) ?? (typeof ep.code === 'string' ? clipped(ep.code, 80) : null);
if (name === null) return [];
const code = typeof ep.code === 'string' ? ep.code.trim().toUpperCase() : '';
const lat = coord(ep.lat, isLat);
const lng = coord(ep.lng, isLng);
const zone = textOf(ep.timezone) ?? (lat !== null && lng !== null ? resolveTimeZone(lat, lng) : null);
return [
{
stop: { name, code: STOP_CODE.test(code) ? code : null, lat, lng },
zone,
localTime: ep.local_time,
localDate: dateOf(ep.local_date),
},
];
});
}
/** The stops a card shows: past nine, the first eight and the last. */
function shownStops(route: RouteStop[]): BookingStop[] {
const stops = route.map((entry) => entry.stop);
return stops.length > MAX_STOPS ? [...stops.slice(0, MAX_STOPS - 1), stops[stops.length - 1] as BookingStop] : stops;
}
function storedLegs(row: BookingRow, type: string, route: RouteStop[]): Row[] | null {
const legs = Array.isArray(row.meta.legs) ? row.meta.legs.map(recordOf) : null;
const hasLegs = (LEG_RESERVATION_TYPES as readonly string[]).includes(type);
return hasLegs && legs && legs.length >= 1 && legs.length <= 8 && legs.length === route.length - 1 ? legs : null;
}
function legOf(
row: BookingRow,
scope: BookingScope,
leg: Row,
from: RouteStop | undefined,
to: RouteStop | undefined,
last: boolean,
): BookingLeg {
const departs = clockOf(leg.dep_time);
const arrives = clockOf(leg.arr_time);
const departsDay = dayFor(row, scope, leg.dep_day_id, from?.localDate ?? null);
const arrivesDay = dayFor(row, scope, leg.arr_day_id, last ? (to?.localDate ?? null) : null);
return {
carrier: clipped(leg.airline, 60),
number: clipped(leg.flight_number ?? leg.train_number, 20),
departs,
departsDay,
arrives,
arrivesDay,
minutes: minutesBetween(
{ date: departsDay?.date ?? null, time: departs, zone: from?.zone ?? null },
{ date: arrivesDay?.date ?? null, time: arrives, zone: to?.zone ?? null },
LEG_MAX_MINUTES,
),
seat: clipped(leg.seat, 20),
platform: clipped(leg.platform, 20),
};
}
/** The one leg of a direct booking, or of one whose segments do not line up with its stops. */
function singleLeg(row: BookingRow, scope: BookingScope, route: RouteStop[]): BookingLeg {
const from = route[0];
const to = route.length >= 2 ? route[route.length - 1] : undefined;
const departs = clockOf(textOf(from?.localTime) ?? row.reservationTime);
const arrives = clockOf(textOf(to?.localTime) ?? row.reservationEndTime);
const departsDay = dayFor(
row,
scope,
row.dayId,
from?.localDate ?? dateOf(row.reservationTime) ?? dateOf(row.dayDate),
);
const arrivesDay = dayFor(row, scope, row.endDayId, to?.localDate ?? dateOf(row.reservationEndTime));
return {
carrier: clipped(row.meta.airline ?? row.meta.carrier, 60),
number: clipped(row.meta.flight_number ?? row.meta.train_number, 20),
departs,
departsDay,
arrives,
arrivesDay,
minutes: minutesBetween(
{ date: departsDay?.date ?? null, time: departs, zone: from?.zone ?? null },
{ date: arrivesDay?.date ?? null, time: arrives, zone: to?.zone ?? null },
LEG_MAX_MINUTES,
),
seat: clipped(row.meta.seat, 20),
platform: clipped(row.meta.platform, 20),
};
}
function ticketOf(row: BookingRow, scope: BookingScope, type: string): BookingTicket {
const route = routeOf(row.endpoints);
const stored = storedLegs(row, type, route);
const legs = stored
? stored.map((leg, i) => legOf(row, scope, leg, route[i], route[i + 1], i === stored.length - 1))
: [singleLeg(row, scope, route)];
const first = legs[0] as BookingLeg;
const last = legs[legs.length - 1] as BookingLeg;
const located = route.filter((entry) => entry.stop.lat !== null && entry.stop.lng !== null).length;
return {
shape: 'ticket',
stops: shownStops(route),
legs,
minutes: minutesBetween(
{ date: first.departsDay?.date ?? null, time: first.departs, zone: route[0]?.zone ?? null },
{ date: last.arrivesDay?.date ?? null, time: last.arrives, zone: route[route.length - 1]?.zone ?? null },
TRIP_MAX_MINUTES,
),
mappable: row.id !== null && located >= 2,
};
}
function nightsOf(start: CardDay | null, end: CardDay | null): number | null {
if (start?.number != null && end?.number != null) {
const nights = end.number - start.number;
return nights >= 0 && nights <= 366 ? nights : null;
}
if (start?.date && end?.date) {
const span = Date.parse(`${end.date}T00:00:00Z`) - Date.parse(`${start.date}T00:00:00Z`);
const nights = Math.round(span / 86_400_000);
return Number.isFinite(nights) && nights >= 0 && nights <= 366 ? nights : null;
}
return null;
}
function photoKeyOf(value: unknown): string | null {
return typeof value === 'string' && cardPhotoKeySchema.safeParse(value).success ? value : null;
}
function stayOf(row: BookingRow, scope: BookingScope, stay: StaySource | null): BookingStay {
const placeId = stay?.placeId ?? null;
const place = row.tripId === scope.tripId ? scope.place(placeId) : null;
const start = stay ? dayFor(row, scope, stay.startDayId, stay.startDate) : null;
const end = stay ? dayFor(row, scope, stay.endDayId, stay.endDate) : null;
return {
shape: 'stay',
accommodationId: stay?.accommodationId ?? null,
placeId,
address: clipped(stay?.address ?? place?.address, 200),
lat: coord(stay?.lat ?? place?.lat, isLat),
lng: coord(stay?.lng ?? place?.lng, isLng),
photoKey: photoKeyOf(stay?.photoKey ?? place?.googlePlaceId),
checkIn: start ? { day: start, from: clockOf(stay?.checkIn), until: clockOf(stay?.checkInEnd) } : null,
checkOut: end ? { day: end, by: clockOf(stay?.checkOut) } : null,
nights: nightsOf(start, end),
};
}
function venueOf(row: BookingRow, scope: BookingScope): BookingVenue {
const local = row.tripId === scope.tripId;
const placeId = idOf(row.placeId) ?? (local ? scope.assignmentPlace(row.assignmentId) : null);
const place = local ? scope.place(placeId) : null;
return {
shape: 'venue',
day: dayFor(row, scope, row.dayId, dateOf(row.reservationTime) ?? dateOf(row.dayDate)),
starts: clockOf(row.reservationTime),
ends: clockOf(row.reservationEndTime),
location: clipped(row.location, 200) ?? clipped(row.placeName, 200) ?? clipped(place?.name, 200),
placeId,
lat: coord(place?.lat ?? row.point?.lat, isLat),
lng: coord(place?.lng ?? row.point?.lng, isLng),
};
}
/** The booking's card; null for one without a title (nor a place to name it by). */
export function bookingItemOf(
row: BookingRow,
scope: BookingScope,
changed: readonly BookingChangedField[] = [],
): BookingItem | null {
const type = typeOf(row);
const title = clipped(row.title, 200) ?? (row.stay ? clipped(row.stay.name, 200) : null);
if (title === null) return null;
const { people, more } = travelersOf(row.travelers);
const body = isTransportReservationType(type)
? ticketOf(row, scope, type)
: type === 'hotel' || row.stay
? stayOf(row, scope, row.stay)
: venueOf(row, scope);
return {
tripId: row.tripId,
tripTitle: row.tripId === scope.tripId ? null : clipped(row.tripTitle, 120),
reservationId: row.id,
type,
title,
status: statusOf(row, type),
needsReview: row.needsReview,
hasReference: hasReference(row),
price: priceOf(row, scope),
travelers: people,
moreTravelers: more,
changed: [...changed],
body,
};
}
/** A card of up to eight bookings, the rest counted; null when none of them reads. */
export function bookingCard(
change: BookingCard['change'],
items: ReadonlyArray<BookingItem | null>,
total?: number,
): BookingCard | null {
const readable = items.filter((item): item is BookingItem => item !== null);
if (readable.length === 0) return null;
const shown = readable.slice(0, MAX_ITEMS);
return { kind: 'booking', change, items: shown, more: Math.max(0, (total ?? readable.length) - shown.length) };
}
/** What a field of the card reads as, for telling a changed one from an unchanged one. */
function facet(item: BookingItem, field: BookingChangedField): unknown {
const body = item.body;
switch (field) {
case 'title':
case 'type':
case 'status':
case 'price':
return item[field];
case 'travelers':
return item.travelers.map((person) => person.id).concat(item.moreTravelers);
case 'reference':
return item.hasReference;
case 'day':
if (body.shape === 'ticket') return body.legs.map((leg) => [leg.departsDay?.id, leg.departsDay?.date]);
return body.shape === 'venue' ? [body.day?.id, body.day?.date] : null;
case 'time':
if (body.shape === 'ticket') return body.legs.map((leg) => [leg.departs, leg.arrives, leg.arrivesDay?.date]);
return body.shape === 'venue' ? [body.starts, body.ends] : null;
case 'route':
return body.shape === 'ticket' ? body.stops.map((stop) => [stop.name, stop.code]) : null;
case 'stay':
return body.shape === 'stay' ? [body.placeId, body.checkIn?.day.id, body.checkOut?.day.id, body.nights] : null;
case 'checkIn':
return body.shape === 'stay' ? [body.checkIn?.from, body.checkIn?.until] : null;
case 'checkOut':
return body.shape === 'stay' ? (body.checkOut?.by ?? null) : null;
case 'location':
return body.shape === 'venue' ? [body.location, body.placeId] : null;
}
}
/** The fields whose card value an update changes: the card before against the card after. */
export function changedBetween(before: BookingItem, after: BookingItem): BookingChangedField[] {
return BOOKING_CHANGED_FIELDS.filter(
(field) => JSON.stringify(facet(before, field)) !== JSON.stringify(facet(after, field)),
);
}
@@ -0,0 +1,342 @@
import { RESERVATION_METADATA } from '../../../../db/json-columns';
import { decodeJson } from '../../../../utils/json-column';
import type { TripCard } from '../../assistant-context';
import { argId, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { bookingRowOf, dateOf, dayOf, dayOnDate, stayRowOf } from './booking-trip';
import type { BookingItem, CardDay, CardMoney } from '@trek/shared';
/**
* Every booking card is drawn from one shape: a booking as the tools speak of
* it, whatever told of it (a saved row, a summary row, an upcoming row, a
* file's import, the arguments of a held write). `BookingScope` is what the
* card can look up beside it: the trip card on a turn, or, for an applied
* change (decided before any trip card is read), what the stored preview card
* already named.
*/
/** A stay as a booking card reads it: the place, the nights, the times as stored. */
export interface StaySource {
accommodationId: number | null;
placeId: number | null;
startDayId: number | null;
endDayId: number | null;
/** The dates when no day id is known (a file's import, another trip's row). */
startDate: string | null;
endDate: string | null;
checkIn: unknown;
checkInEnd: unknown;
checkOut: unknown;
name: unknown;
address: unknown;
lat: unknown;
lng: unknown;
/** A Google place id, for TREK's photo proxy. */
photoKey: unknown;
hasReference: boolean;
}
export interface BookingRow {
tripId: number;
/** Only for a row of another trip. */
tripTitle: unknown;
id: number | null;
type: unknown;
title: unknown;
status: unknown;
needsReview: boolean;
confirmation: unknown;
reservationTime: unknown;
reservationEndTime: unknown;
dayId: unknown;
endDayId: unknown;
/** The date of the booking's day, where only that is known. */
dayDate: unknown;
location: unknown;
placeId: unknown;
placeName: unknown;
assignmentId: unknown;
meta: Row;
endpoints: Row[];
travelers: Row[];
stay: StaySource | null;
/** A price the source names itself (a write's argument); undefined reads it from the metadata. */
price?: CardMoney | null;
/** Where a venue is when no trip place says so (a file's import names it). */
point?: { lat: unknown; lng: unknown };
}
/** A place as a booking card completes a stay or a venue from it. */
interface ScopePlace {
name: string | null;
address: string | null;
lat: number | null;
lng: number | null;
googlePlaceId: string | null;
}
/** What a booking card looks up beside its source. */
export interface BookingScope {
tripId: number;
currency: string | null;
day(id: unknown): CardDay | null;
dayOn(date: string | null): CardDay | null;
place(id: number | null): ScopePlace | null;
assignmentPlace(id: unknown): number | null;
}
export function idOf(value: unknown): number | null {
return argId({ value }, 'value');
}
/** The stay a booking links: stored as text, read back as `"14"` or `"14.0"` by older rows. */
export function accommodationIdOf(raw: Row): number | null {
const value = raw.accommodation_id;
return idOf(typeof value === 'string' ? Math.trunc(Number(value)) : value);
}
export function textOf(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
/** The scope of a turn: everything the trip card holds. */
export function scopeOfCard(card: TripCard): BookingScope {
return {
tripId: card.tripId,
currency: card.currency,
day: (id) => dayOf(card, id),
dayOn: (date) => dayOnDate(card, date),
place: (id) => (id === null ? null : (card.places.get(id) ?? null)),
assignmentPlace: (id) => card.assignments.get(idOf(id) ?? 0)?.placeId ?? null,
};
}
/** The days a stored card named, by id. */
function daysOfItems(items: readonly BookingItem[]): Map<number, CardDay> {
const days = new Map<number, CardDay>();
const add = (day: CardDay | null | undefined) => {
if (day?.id != null && !days.has(day.id)) days.set(day.id, day);
};
for (const item of items) {
const body = item.body;
if (body.shape === 'ticket') {
for (const leg of body.legs) {
add(leg.departsDay);
add(leg.arrivesDay);
}
} else if (body.shape === 'stay') {
add(body.checkIn?.day);
add(body.checkOut?.day);
} else add(body.day);
}
return days;
}
/**
* The scope of an applied change: the days its preview card named. A day
* the preview did not name keeps its id alone, so the card still links it.
*/
export function scopeOfPreview(items: readonly BookingItem[], tripId: number): BookingScope {
const days = daysOfItems(items);
const first = items[0];
return {
tripId,
currency: first?.price?.currency ?? null,
day: (id) => {
const known = idOf(id);
return known === null ? null : (days.get(known) ?? { id: known, number: null, date: null });
},
dayOn: (date) => (date === null ? null : { id: null, number: null, date }),
place: () => null,
assignmentPlace: () => null,
};
}
/** A booking's metadata: the readable details of a `list_reservations` row, else the stored column. */
function metaOf(raw: Row): Row {
if (raw.details && typeof raw.details === 'object') return recordOf(raw.details);
return typeof raw.metadata === 'string' ? decodeJson(RESERVATION_METADATA, raw.metadata) : recordOf(raw.metadata);
}
/**
* The stay a hotel booking links, completed from the trip's own stay row where
* the trip card has it. Another type reads as itself even while it still links
* the stay it had as a hotel.
*/
function linkedStay(raw: Row, meta: Row, card: TripCard | null): StaySource | null {
if (raw.type !== 'hotel') return null;
const accommodationId = accommodationIdOf(raw);
const placeId = idOf(raw.accommodation_place_id) ?? idOf(raw.place_id);
const stay = card ? stayRowOf(card, accommodationId) : null;
return {
accommodationId,
placeId: idOf(stay?.place_id) ?? placeId,
startDayId: idOf(stay?.start_day_id) ?? idOf(raw.accommodation_start_day_id),
endDayId: idOf(stay?.end_day_id) ?? idOf(raw.accommodation_end_day_id),
startDate: null,
endDate: null,
checkIn: stay?.check_in ?? meta.check_in_time,
checkInEnd: stay?.check_in_end ?? meta.check_in_end_time,
checkOut: stay?.check_out ?? meta.check_out_time,
name: stay?.place_name ?? raw.accommodation_name,
address: stay?.place_address,
lat: stay?.place_lat,
lng: stay?.place_lng,
photoKey: null,
hasReference: textOf(stay?.confirmation) !== null,
};
}
/** A saved booking: a summary row, a `list_reservations` row or what a write answered. */
export function rowOfReservation(raw: Row, tripId: number, card: TripCard | null = null): BookingRow {
const meta = metaOf(raw);
return {
tripId: idOf(raw.trip_id) ?? tripId,
tripTitle: null,
id: idOf(raw.id),
type: raw.type,
title: raw.title,
status: raw.status,
needsReview: raw.needs_review === 1 || raw.needs_review === true,
confirmation: raw.confirmation_number,
reservationTime: raw.reservation_time,
reservationEndTime: raw.reservation_end_time,
dayId: raw.day_id,
endDayId: raw.end_day_id,
dayDate: null,
location: raw.location,
placeId: raw.type === 'hotel' ? null : raw.place_id,
placeName: raw.place_name,
assignmentId: raw.assignment_id,
meta,
endpoints: rowsOf(raw.endpoints),
travelers: rowsOf(raw.travelers),
stay: linkedStay(raw, meta, card),
};
}
/** The booking of this trip that links a stay, from the trip card. */
function bookingOfStay(card: TripCard | null, accommodationId: number | null): Row | null {
if (!card || accommodationId === null) return null;
const summary = recordOf(recordOf(card.sources).summary);
const found = rowsOf(summary.reservations).find((row) => accommodationIdOf(row) === accommodationId);
return found ? (bookingRowOf(card, idOf(found.id)) ?? found) : null;
}
/** The price a place created with its stay carries, as booking metadata reads it. */
function placePrice(place: Row | null): Row {
return place && place.price != null ? { price: place.price, priceCurrency: place.currency } : {};
}
/**
* A stay: an accommodation row with its place joined (`list_accommodations`,
* the summary, what a write answered). `place` overrides the joined place
* (a place created with the stay). The booking linking it gives the status.
*/
export function rowOfStay(
raw: Row,
tripId: number,
card: TripCard | null = null,
place: Row | null = null,
): BookingRow {
const accommodationId = idOf(raw.id);
const booking = bookingOfStay(card, accommodationId);
return {
tripId: idOf(raw.trip_id) ?? tripId,
tripTitle: null,
id: idOf(booking?.id),
type: 'hotel',
title: raw.reservation_title ?? booking?.title ?? place?.name ?? raw.place_name,
status: booking?.status ?? null,
needsReview: booking?.needs_review === 1 || booking?.needs_review === true,
confirmation: booking?.confirmation_number,
reservationTime: null,
reservationEndTime: null,
dayId: null,
endDayId: null,
dayDate: null,
location: null,
placeId: null,
placeName: null,
assignmentId: null,
meta: { ...placePrice(place), ...(booking ? metaOf(booking) : {}) },
endpoints: [],
travelers: booking ? rowsOf(booking.travelers) : [],
stay: {
accommodationId,
placeId: idOf(place?.id) ?? idOf(raw.place_id),
startDayId: idOf(raw.start_day_id),
endDayId: idOf(raw.end_day_id),
startDate: null,
endDate: null,
checkIn: raw.check_in,
checkInEnd: raw.check_in_end,
checkOut: raw.check_out,
name: place?.name ?? raw.place_name,
address: place?.address ?? raw.place_address,
lat: place?.lat ?? raw.place_lat,
lng: place?.lng ?? raw.place_lng,
photoKey: place?.google_place_id ?? null,
hasReference: textOf(raw.confirmation) !== null,
},
};
}
/**
* A row of `list_upcoming_reservations`, which spans every trip: no endpoints
* and no metadata; a stay comes as its check-in or its check-out moment.
*/
export function rowOfUpcoming(raw: Row, tripId: number): BookingRow {
const moment = raw.type === 'checkin' || raw.type === 'checkout' ? raw.type : null;
const date = dateOf(raw.day_date) ?? dateOf(raw.reservation_time);
const rowTrip = idOf(raw.trip_id) ?? tripId;
return {
tripId: rowTrip,
tripTitle: rowTrip === tripId ? null : raw.trip_title,
id: moment ? null : idOf(raw.id),
type: moment ? 'hotel' : raw.type,
title: raw.title,
status: moment ? null : raw.status,
needsReview: false,
confirmation: raw.confirmation_number,
reservationTime: raw.reservation_time,
reservationEndTime: null,
dayId: null,
endDayId: null,
dayDate: date,
location: raw.location ?? raw.place_name,
placeId: null,
placeName: null,
assignmentId: null,
meta: {},
endpoints: [],
travelers: [],
stay: moment
? {
accommodationId: idOf(raw.id),
placeId: null,
startDayId: null,
endDayId: null,
startDate: moment === 'checkin' ? date : null,
endDate: moment === 'checkout' ? date : null,
checkIn: moment === 'checkin' ? raw.reservation_time : null,
checkInEnd: null,
checkOut: moment === 'checkout' ? raw.reservation_time : null,
name: raw.place_name,
address: null,
lat: null,
lng: null,
photoKey: null,
hasReference: false,
}
: null,
};
}
/** Whether a booking's legs carry a reference of their own (`list_reservations` says so without the value). */
export function legsHaveReference(meta: Row): boolean {
if (meta.has_leg_reference === true) return true;
return (
Array.isArray(meta.legs) && meta.legs.some((leg: unknown) => textOf(recordOf(leg).confirmation_number) !== null)
);
}
@@ -0,0 +1,66 @@
import type { TripCard } from '../../assistant-context';
import { recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import type { CardDay } from '@trek/shared';
/**
* The trip's bookings and stays as `get_trip_summary` answered them on this
* turn, by id, read once per trip card. The trip card itself keeps only a
* booking's title and day for the prompt; a booking card needs the whole row
* (its endpoints, travellers, metadata and the stay it links).
*/
interface TripBookings {
reservations: ReadonlyMap<number, Row>;
stays: ReadonlyMap<number, Row>;
}
const PARSED = new WeakMap<TripCard, TripBookings>();
function byId(rows: Row[]): Map<number, Row> {
const out = new Map<number, Row>();
for (const row of rows) {
if (typeof row.id === 'number' && Number.isInteger(row.id) && row.id > 0 && !out.has(row.id)) out.set(row.id, row);
}
return out;
}
function parsed(card: TripCard): TripBookings {
let found = PARSED.get(card);
if (!found) {
const summary = recordOf(recordOf(card.sources).summary);
found = { reservations: byId(rowsOf(summary.reservations)), stays: byId(rowsOf(summary.accommodations)) };
PARSED.set(card, found);
}
return found;
}
/** A booking of this trip as the summary holds it, joins included. */
export function bookingRowOf(card: TripCard, id: number | null): Row | null {
return id === null ? null : (parsed(card).reservations.get(id) ?? null);
}
/** A stay of this trip as the summary holds it, its place joined. */
export function stayRowOf(card: TripCard, id: number | null): Row | null {
return id === null ? null : (parsed(card).stays.get(id) ?? null);
}
/** A day of this trip by its id; null for one the trip does not hold. */
export function dayOf(card: TripCard, id: unknown): CardDay | null {
const day = typeof id === 'number' ? card.days.find((d) => d.id === id) : undefined;
return day ? { id: day.id, number: day.number, date: day.date } : null;
}
const ISO_DATE = /^(\d{4}-\d{2}-\d{2})/;
/** The date part of a date or a date and time, as written. */
export function dateOf(value: unknown): string | null {
return typeof value === 'string' ? (ISO_DATE.exec(value.trim())?.[1] ?? null) : null;
}
/** The trip day on a date; a date the trip has no day for still names its date. */
export function dayOnDate(card: TripCard | null, date: string | null): CardDay | null {
if (date === null) return null;
const day = card?.days.find((d) => d.date === date);
return day ? { id: day.id, number: day.number, date } : { id: null, number: null, date };
}
@@ -0,0 +1,135 @@
import type { PreviewContext, ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { argId, clipped, finite, idIn, isLat, isLng, recordOf, type Row } from '../card-values';
import { byId, rowsOf, same, snapshotAssignments, snapshotPlaces, STABLE_FIELDS } from '../undo-snapshot';
import { accommodationIdOf } from './booking-row';
import { stayRowOf } from './booking-trip';
import { categoryIdForKind, type AssistantChangeEffect } from '@trek/shared';
/**
* The way back from a created booking or stay, and what a held stay does to
* the day plan. A stay puts its place on its check-in day, so the pending
* stay shows as a ghost pin and in the day's preview like any new stop; undo
* removes the booking (a hotel takes its stay along) or the stay (a stay
* takes its stop along), or the place a stay was created with.
*/
type Undo = NonNullable<ToolCards['undo']>;
/** What still says a booking is the one the change created. */
const BOOKING_FIELDS = [
'title',
'type',
'status',
'reservation_time',
'reservation_end_time',
'day_id',
'end_day_id',
'location',
] as const;
/** What still says a stay is the one the change created. */
const STAY_FIELDS = ['place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out'] as const;
function summaryRows(snapshot: UndoSnapshot, key: string): Row[] {
return rowsOf(recordOf(snapshot.summary)[key]);
}
/** A created booking, still as it was created. */
function bookingUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).reservation);
return same(created, byId(summaryRows(snapshot, 'reservations'), created.id), BOOKING_FIELDS);
}
/** A created stay, still as it was created. */
function stayUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).accommodation);
return same(created, byId(summaryRows(snapshot, 'accommodations'), created.id), STAY_FIELDS);
}
/**
* A place created with its stay, untouched and used by nothing but that stay:
* not planned on another day, in no other booking, stay or expense. The stay
* itself and the booking that links it do not count; they go with the place.
*/
function placeOfStayUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const answer = recordOf(result);
const created = recordOf(answer.place);
if (!same(created, byId(snapshotPlaces(snapshot), created.id), STABLE_FIELDS.place)) return false;
const stayId = recordOf(answer.accommodation).id;
const ownStop = recordOf(answer.assignment).id;
const plannedElsewhere = snapshotAssignments(snapshot).some((a) => a.place_id === created.id && a.id !== ownStop);
const otherBooking = summaryRows(snapshot, 'reservations').some(
(r) => r.place_id === created.id && accommodationIdOf(r) !== stayId,
);
const otherStay = summaryRows(snapshot, 'accommodations').some((a) => a.place_id === created.id && a.id !== stayId);
const paid = rowsOf(recordOf(recordOf(snapshot.summary).budget).items).some((item) => item.place_id === created.id);
return !plannedElsewhere && !otherBooking && !otherStay && !paid && stayUnchanged(result, snapshot);
}
function byResultId(key: string, arg: string) {
return (_args: ToolArgs, result: unknown): ToolArgs | null => {
const id = idIn(result, key);
return id ? { [arg]: id } : null;
};
}
export const BOOKING_UNDO: Readonly<Record<string, Undo>> = {
create_reservation: {
tool: 'delete_reservation',
args: byResultId('reservation', 'reservationId'),
unchanged: bookingUnchanged,
},
create_transport: {
tool: 'delete_transport',
args: byResultId('reservation', 'reservationId'),
unchanged: bookingUnchanged,
},
create_accommodation: {
tool: 'delete_accommodation',
args: byResultId('accommodation', 'accommodationId'),
unchanged: stayUnchanged,
},
create_place_accommodation: {
tool: 'delete_place',
args: byResultId('place', 'placeId'),
unchanged: placeOfStayUnchanged,
},
};
/** A change to the day plan, with nothing of what the kind does not name. */
function effect(
kind: AssistantChangeEffect['kind'],
dayId: number | null,
fields: Partial<Omit<AssistantChangeEffect, 'kind' | 'dayId'>>,
): AssistantChangeEffect | null {
if (dayId === null) return null;
return { kind, dayId, fromDayId: null, placeId: null, assignmentId: null, place: null, ...fields };
}
type EffectBuilder = (ctx: PreviewContext, args: ToolArgs) => AssistantChangeEffect | null;
export const BOOKING_EFFECTS: Readonly<Record<string, EffectBuilder>> = {
create_accommodation: (_ctx, args) =>
effect('add', argId(args, 'start_day_id'), { placeId: argId(args, 'place_id') }),
create_place_accommodation: (ctx, args) => {
const lat = finite(args.lat);
const lng = finite(args.lng);
const name = clipped(args.name, 200);
const start = argId(args, 'start_day_id');
// Without a position there is no pin to show, so no stop is previewed.
if (!isLat(lat) || !isLng(lng) || name === null) return null;
const categories = [...ctx.categories].map(([id, label]) => ({ id, name: label }));
return effect('add', start, { place: { name, lat, lng, categoryId: categoryIdForKind('lodging', categories) } });
},
create_reservation: (_ctx, args) => {
const placeId = argId(args, 'place_id');
const hotel = args.type === 'hotel' && placeId !== null && argId(args, 'end_day_id') !== null;
return hotel ? effect('add', argId(args, 'start_day_id'), { placeId }) : null;
},
update_accommodation: (ctx, args) => {
const current = recordOf(stayRowOf(ctx.card, argId(args, 'accommodationId')));
const from = argId(current, 'start_day_id');
const to = argId(args, 'start_day_id');
if (to === null || to === from) return null;
return effect('move', to, { fromDayId: from, placeId: argId(args, 'place_id') ?? argId(current, 'place_id') });
},
};
@@ -1,4 +1,125 @@
import type { DomainCards } from '../card-types';
import { IMPORT_BOOKING_TOOL } from '../../assistant-tools.constants';
import type { TripCard } from '../../assistant-context';
import type { DomainCards, PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, idIn } from '../card-values';
import { BOOKING_ANSWERS } from './booking-answer';
import { appliedImport, appliedReservation, appliedStay, appliedTravelers } from './booking-applied';
import { BOOKING_CHANGE_CARDS, importedBookingCard } from './booking-change';
import { BOOKING_DESCRIBERS } from './booking-describe';
import { stayRowOf } from './booking-trip';
import { BOOKING_EFFECTS, BOOKING_UNDO } from './booking-undo';
import type { TrekCard } from '@trek/shared';
/** The cards of the bookings: flights, trains, stays and other reservations. None yet: this package fills them in. */
export const BOOKING_CARDS: DomainCards = { id: 'booking', tools: {} };
/**
* The bookings in the assistant chat: tickets (flights, trains, hire cars),
* stays and venues, as the planner's booking cards show them. Every booking
* write and read gets its card; the curated writes name their own verb
* ("Add accommodation" instead of a bare "Add") and the created ones can be
* undone. `create_reservation` and a file's `import_booking` keep the core's
* words; their cards come from here.
*/
type ChangeOf = (ctx: PreviewContext, args: ToolArgs) => TrekCard | null;
const change = (tool: string): ChangeOf => BOOKING_CHANGE_CARDS[tool] ?? (() => null);
/** The stay an id argument names: its place, else the booking that links it. */
function stayName(key: string, value: number, card: TripCard): string | null {
if (key !== 'accommodationId') return null;
const stay = stayRowOf(card, value);
return stay ? clipped(stay.place_name ?? stay.reservation_title, 160) : null;
}
/** The stay a stay write touched, by the key its answer names it under (the place and the stop come too). */
const stayIds = (_args: ToolArgs, result: unknown): number[] => {
const id = idIn(result, 'accommodation');
return id ? [id] : [];
};
const ADD_STAY = { verbKey: 'addStay', entity: 'accommodation' } as const;
const UPDATE_RESERVATION = { verbKey: 'updateReservation', entity: 'reservation' } as const;
export const BOOKING_CARDS: DomainCards = {
id: 'booking',
prompt: [
'Bookings and stays are drawn as cards beside your answer: do not repeat their times, route or reference. Read them with list_reservations (flight numbers, seats, check-in times) and list_accommodations; get_trip_summary leaves those details out.',
'A hotel not yet in the trip: search_place, then create_place_accommodation; one that is: create_accommodation.',
],
tools: {
list_reservations: { answer: BOOKING_ANSWERS.list_reservations },
list_accommodations: { answer: BOOKING_ANSWERS.list_accommodations },
list_upcoming_reservations: { answer: BOOKING_ANSWERS.list_upcoming_reservations },
create_reservation: {
change: change('create_reservation'),
effect: BOOKING_EFFECTS.create_reservation,
applied: appliedReservation,
undo: BOOKING_UNDO.create_reservation,
},
create_transport: {
write: { verbKey: 'addReservation', entity: 'reservation' },
describe: BOOKING_DESCRIBERS.create_transport,
change: change('create_transport'),
applied: appliedReservation,
undo: BOOKING_UNDO.create_transport,
},
update_reservation: {
write: UPDATE_RESERVATION,
describe: BOOKING_DESCRIBERS.update_reservation,
change: change('update_reservation'),
applied: appliedReservation,
},
update_transport: {
write: UPDATE_RESERVATION,
describe: BOOKING_DESCRIBERS.update_transport,
change: change('update_transport'),
applied: appliedReservation,
},
set_reservation_travelers: {
write: UPDATE_RESERVATION,
describe: BOOKING_DESCRIBERS.set_reservation_travelers,
change: change('set_reservation_travelers'),
applied: appliedTravelers,
appliedIds: (args) => {
const id = argId(args, 'reservationId');
return id ? [id] : [];
},
},
link_hotel_accommodation: {
write: { verbKey: 'updateStay', entity: 'reservation' },
describe: BOOKING_DESCRIBERS.link_hotel_accommodation,
change: change('link_hotel_accommodation'),
applied: appliedReservation,
},
delete_reservation: { change: change('delete_reservation') },
delete_transport: { change: change('delete_transport') },
create_accommodation: {
write: ADD_STAY,
describe: BOOKING_DESCRIBERS.create_accommodation,
effect: BOOKING_EFFECTS.create_accommodation,
change: change('create_accommodation'),
applied: appliedStay,
appliedIds: stayIds,
undo: BOOKING_UNDO.create_accommodation,
},
create_place_accommodation: {
write: ADD_STAY,
describe: BOOKING_DESCRIBERS.create_place_accommodation,
effect: BOOKING_EFFECTS.create_place_accommodation,
change: change('create_place_accommodation'),
applied: appliedStay,
appliedIds: stayIds,
undo: BOOKING_UNDO.create_place_accommodation,
},
update_accommodation: {
write: { verbKey: 'updateStay', entity: 'accommodation' },
describe: BOOKING_DESCRIBERS.update_accommodation,
effect: BOOKING_EFFECTS.update_accommodation,
change: change('update_accommodation'),
applied: appliedStay,
appliedIds: stayIds,
nameArg: stayName,
},
delete_accommodation: { change: change('delete_accommodation'), nameArg: stayName },
[IMPORT_BOOKING_TOOL]: { change: importedBookingCard, applied: appliedImport },
},
};
+1 -25
View File
@@ -9,7 +9,7 @@ import type { ReservationsRepository } from '../../db/repositories/Reservations.
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { decodeJson } from '../../utils/json-column';
import { NotFoundError } from '../common/domain-errors';
import { resolveTimeZone } from '../common/timezoneService';
import { offsetMinutesAt, resolveTimeZone } from '../common/timezoneService';
import { addDays } from '../days/days.service';
import { ReservationsService } from '../reservations/reservations.service';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -87,30 +87,6 @@ function isValidTimeZone(zone: string): boolean {
return ok;
}
// One formatter per zone: the transition scan below asks it about once a day
// across the calendar's span.
const _offsetFormatters = new Map<string, Intl.DateTimeFormat>();
function offsetFormatter(zone: string): Intl.DateTimeFormat {
let f = _offsetFormatters.get(zone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' });
if (_offsetFormatters.size >= 1000) _offsetFormatters.clear();
_offsetFormatters.set(zone, f);
}
return f;
}
/** The zone's UTC offset at instant `ms`, in minutes east of UTC. */
function offsetMinutesAt(zone: string, ms: number): number {
const raw =
offsetFormatter(zone)
.formatToParts(new Date(ms))
.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/);
if (!m) return 0; // "GMT" (UTC) has no offset digits
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
}
/** An offset in minutes as iCalendar writes it: "+0200", "-0330". */
function formatOffset(minutes: number): string {
const abs = Math.abs(minutes);
+47
View File
@@ -37,6 +37,53 @@ export function resolveTimeZone(lat: unknown, lng: unknown): string | null {
}
}
// One formatter per zone: the calendar's transition scan asks it about once a
// day across its span, and every booking card asks it for both ends of a leg.
const offsetFormatters = new Map<string, Intl.DateTimeFormat>();
function offsetFormatter(zone: string): Intl.DateTimeFormat {
let f = offsetFormatters.get(zone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' });
if (offsetFormatters.size >= 1000) offsetFormatters.clear();
offsetFormatters.set(zone, f);
}
return f;
}
/** The zone's UTC offset at instant `ms`, in minutes east of UTC. Throws a RangeError on an unknown zone. */
export function offsetMinutesAt(zone: string, ms: number): number {
const raw =
offsetFormatter(zone)
.formatToParts(new Date(ms))
.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/);
if (!m) return 0; // "GMT" (UTC) has no offset digits
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
}
/**
* The instant a wall clock (`YYYY-MM-DD`, `HH:MM`) reads in an IANA zone, in
* ms since the epoch; null for a malformed or impossible date, a malformed time
* or an unknown zone. A clock a spring-forward skips lands an hour later, as a
* clock set by hand would.
*/
export function wallClockInstant(date: string, time: string, zone: string): number | null {
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
const t = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
if (!d || !t) return null;
const asUtc = Date.UTC(Number(d[1]), Number(d[2]) - 1, Number(d[3]), Number(t[1]), Number(t[2]));
// A day the month does not have (31 February) rolls over into the next month; refuse it instead.
if (Number.isNaN(asUtc) || new Date(asUtc).toISOString().slice(0, 10) !== date) return null;
try {
const first = asUtc - offsetMinutesAt(zone, asUtc) * 60_000;
// The offset in force at the first guess can differ from the one at the
// instant itself across a change of offset; one more step settles it.
return asUtc - offsetMinutesAt(zone, first) * 60_000;
} catch {
return null;
}
}
/**
* Today's date and the clock time where the instance runs, in the TZ the crons
* are scheduled in (CronRegistrarService). A UTC "today" disagreed with the
@@ -0,0 +1,120 @@
import { RESERVATION_METADATA } from '../../db/json-columns';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, ok, Tool, TOOL_ANNOTATIONS_READONLY, type McpContext } from '../../nest-mcp';
import { decodeJson } from '../../utils/json-column';
import { ReservationsService, type ReservationRow } from './reservations.service';
import {
isTransportReservationType,
listReservationsRequestSchema,
type ListReservationsRequest,
type ReservationReadKind,
} from '@trek/shared';
type Row = Record<string, unknown>;
/** The metadata keys a person reads on a booking; the rest (provider ids, a transit itinerary) stays stored. */
const DETAIL_KEYS = [
'airline',
'flight_number',
'train_number',
'platform',
'seat',
'check_in_time',
'check_in_end_time',
'check_out_time',
'price',
'priceCurrency',
] as const;
/** What a leg says about itself; its own booking reference stays with the booking. */
const LEG_KEYS = [
'from',
'to',
'airline',
'flight_number',
'train_number',
'platform',
'seat',
'dep_day_id',
'dep_time',
'arr_day_id',
'arr_time',
] as const;
function recordOf(value: unknown): Row {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
}
function picked(source: Row, keys: readonly string[]): Row {
const out: Row = {};
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' || typeof value === 'number') out[key] = value;
}
return out;
}
/**
* A booking's readable details out of its stored metadata: what the planner
* shows on it. A reference kept on a leg stays stored too; `has_leg_reference`
* says only that one exists.
*/
function bookingDetails(raw: unknown): Row {
const meta = typeof raw === 'string' ? decodeJson(RESERVATION_METADATA, raw) : recordOf(raw);
const details = picked(meta, DETAIL_KEYS);
const legs = Array.isArray(meta.legs) ? meta.legs.map(recordOf) : [];
if (legs.length > 1) details.legs = legs.map((leg) => picked(leg, LEG_KEYS));
const ref = (leg: Row) => typeof leg.confirmation_number === 'string' && leg.confirmation_number.trim() !== '';
if (legs.some(ref)) details.has_leg_reference = true;
return details;
}
/** The days a booking touches: where it starts and ends, a leg's departure and arrival, a stay's check-in. */
function daysOf(row: ReservationRow, details: Row): number[] {
const legs = Array.isArray(details.legs) ? details.legs.map(recordOf) : [];
return [
row.day_id,
row.end_day_id,
row.accommodation_start_day_id,
...legs.flatMap((leg) => [leg.dep_day_id, leg.arr_day_id]),
].filter((value): value is number => typeof value === 'number');
}
function kindOf(row: ReservationRow): ReservationReadKind {
return isTransportReservationType(row.type) ? 'transport' : 'booking';
}
/**
* The trip's bookings as an assistant reads them, beside the summary every
* turn loads. The summary hands the provider no metadata (it may hold
* provider ids and a whole transit itinerary), so a flight number, a seat or
* a check-in time never reached the model; this read carries them as
* `details`, the booking reference still hidden by the assistant's redaction.
* Read-only, core (offered on every step) and gated like the trip's other
* booking reads.
*/
@McpController()
export class ReservationsReadMcp {
constructor(private readonly reservations: ReservationsService) {}
@Tool({
name: 'list_reservations',
description:
'List the bookings of a trip with their readable details: flights, trains and hire cars (kinds ["transport"]), hotels, tables, tickets and tours (kinds ["booking"]), or both. Each carries its days, times, route endpoints, status, travellers and `details` (airline, flight or train number, platform, seat, legs of a stopover booking, check-in and check-out times, price). Use it to answer about bookings instead of get_trip_summary, which leaves these details out. dayId keeps the bookings on that day.',
inputSchema: listReservationsRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'reservations', mode: 'read' },
})
async listReservations({ tripId, kinds, dayId }: ListReservationsRequest, ctx: McpContext) {
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const wanted = new Set<ReservationReadKind>(kinds ?? ['transport', 'booking']);
const reservations = (await this.reservations.list(tripId)).flatMap((row) => {
const { metadata, ...rest } = row;
const details = bookingDetails(metadata);
if (!wanted.has(kindOf(row))) return [];
if (dayId !== undefined && !daysOf(row, details).includes(dayId)) return [];
return [{ ...rest, details }];
});
return ok({ reservations });
}
}
@@ -21,6 +21,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { ReservationWritesService } from './reservation-writes.service';
import { ReservationsReadMcp } from './reservations-read.mcp';
import { ReservationsReadModule } from './reservations-read.module';
import { ReservationsController } from './reservations.controller';
import { ReservationsMcp } from './reservations.mcp';
@@ -81,7 +82,8 @@ import { Module } from '@nestjs/common';
],
controllers: [ReservationsController, UpcomingReservationsController],
// ReservationWritesService: the booking use cases the controller and the MCP tools share.
providers: [ReservationsService, ReservationWritesService, ReservationsMcp, ReservationsRpc],
// ReservationsReadMcp: the assistant's read of the bookings with their readable details.
providers: [ReservationsService, ReservationWritesService, ReservationsMcp, ReservationsReadMcp, ReservationsRpc],
// For in-container consumers (ReservationsRpc, TripsService, BookingImportService).
exports: [ReservationsService],
})
@@ -0,0 +1,111 @@
/**
* The assistant's booking reads over a real MCP session against the booted
* container: list_reservations answers the bookings with their readable
* details and no raw metadata, list_accommodations the stays with their place,
* and neither answers for a trip the caller cannot see.
*/
import { buildApp } from '../../src/bootstrap';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { makeDayAccommodation } from '../helpers/factories/itinerary';
import { makePlace } from '../helpers/factories/places';
import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations';
import { makeDay, makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import type { INestApplication } from '@nestjs/common';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
let app: INestApplication;
let orm: MikroORM;
beforeAll(async () => {
app = await buildApp();
orm = app.get(MikroORM);
}, 60_000);
afterAll(async () => {
await app?.close();
});
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
const server = createLocalMcpServer('TREK booking reads e2e');
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
await withRequestContext(orm, () =>
app
.get(McpRegistryService)
.attach(
server,
{ userId, scopes: null, isStaticToken: false },
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'booking-reads-e2e', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const result = await client.callTool({ name, arguments: args });
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
const text = content[0]?.text ?? '';
return result.isError === true ? { error: text } : JSON.parse(text);
} finally {
await client.close();
await server.close();
}
}
describe('the booking reads an assistant makes over MCP', () => {
it('RESREAD-E2E-001: answers the bookings with their details and the stays with their place', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Berlin' });
const day1 = await makeDay(orm, trip.id, { day_number: 1, date: '2026-06-03' });
const day2 = await makeDay(orm, trip.id, { day_number: 2, date: '2026-06-04' });
const flight = await makeReservation(orm, trip.id, {
title: 'Frankfurt to Berlin',
type: 'flight',
day: day1.id,
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 170', airtrail_ids: [1] }),
});
await makeReservationEndpoint(orm, flight.id, { role: 'from', sequence: 0, name: 'Frankfurt', code: 'FRA' });
await makeReservationEndpoint(orm, flight.id, { role: 'to', sequence: 1, name: 'Berlin', code: 'BER' });
const hotel = await makePlace(orm, trip.id, { name: 'Mercure', lat: 52.53, lng: 13.38 });
await makeDayAccommodation(orm, trip.id, hotel.id, day1.id, day2.id, { check_in: '15:00', check_out: '11:00' });
const read = await callTool(user.id, 'list_reservations', { tripId: trip.id, kinds: ['transport'] });
expect(read.reservations).toHaveLength(1);
expect(read.reservations[0]).toMatchObject({
id: flight.id,
title: 'Frankfurt to Berlin',
details: { airline: 'Lufthansa', flight_number: 'LH 170' },
endpoints: [expect.objectContaining({ code: 'FRA' }), expect.objectContaining({ code: 'BER' })],
});
expect(read.reservations[0]).not.toHaveProperty('metadata');
expect(read.reservations[0].details).not.toHaveProperty('airtrail_ids');
const stays = await callTool(user.id, 'list_accommodations', { tripId: trip.id });
expect(stays.accommodations).toEqual([
expect.objectContaining({
place_id: hotel.id,
place_name: 'Mercure',
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_out: '11:00',
}),
]);
});
it('RESREAD-E2E-002: answers neither read for a trip the caller is not on', async () => {
const { user } = await makeUser(orm);
const { user: stranger } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
expect(await callTool(stranger.id, 'list_reservations', { tripId: trip.id })).toHaveProperty('error');
expect(await callTool(stranger.id, 'list_accommodations', { tripId: trip.id })).toHaveProperty('error');
});
});
@@ -1155,3 +1155,179 @@ describe('Tool: list_upcoming_reservations', () => {
});
});
});
// ---------------------------------------------------------------------------
// list_reservations / list_accommodations
//
// The assistant's booking reads: the bookings with their readable details,
// never the raw metadata (provider ids, a leg's own reference), and the stays
// with their place, each behind the trip's access check and its scope.
// ---------------------------------------------------------------------------
async function withScopedHarness(userId: number, fn: (h: McpHarness) => Promise<void>, scopes: string[] | null = null) {
const h = await createMcpHarness({ realtime, userId, withResources: false, scopes });
try {
await fn(h);
} finally {
await h.cleanup();
}
}
interface ListedBooking {
id: number;
title: string;
type: string;
metadata?: unknown;
details: Record<string, unknown>;
}
async function listed(userId: number, args: Record<string, unknown>): Promise<ListedBooking[]> {
let out: ListedBooking[] = [];
await withScopedHarness(userId, async (h) => {
const data = parseToolResult(await h.client.callTool({ name: 'list_reservations', arguments: args })) as {
reservations: ListedBooking[];
};
out = data.reservations;
});
return out;
}
/** A trip with a flight with stopover legs on day 1, a dinner on day 2 and a hotel stay. */
async function seedTrip(userId: number) {
const trip = createTrip(testDb, userId);
const day1 = createDay(testDb, trip.id, { date: '2026-06-03' });
const day2 = createDay(testDb, trip.id, { date: '2026-06-04' });
const flight = await insertRow(orm, Reservations, {
trip: trip.id,
day: day1.id,
title: 'Frankfurt to New York',
type: 'flight',
status: 'confirmed',
confirmation_number: 'ABC123',
metadata: JSON.stringify({
airline: 'Lufthansa',
flight_number: 'LH 400',
seat: '12A',
airtrail_ids: [4, 5],
legs: [
{ from: 'FRA', to: 'MUC', flight_number: 'LH 101', confirmation_number: 'LEG-REF', dep_time: '08:15' },
{ from: 'MUC', to: 'JFK', flight_number: 'LH 410', arr_day_id: day2.id, arr_time: '13:05' },
],
}),
});
const dinner = await insertRow(orm, Reservations, {
trip: trip.id,
day: day2.id,
title: 'Dinner',
type: 'restaurant',
metadata: JSON.stringify({ price: '80' }),
});
return { trip, day1, day2, flight, dinner };
}
describe('Tool: list_reservations', () => {
it('RESREAD-MCP-001: lists the bookings with their readable details and without the raw metadata', async () => {
const { user } = createUser(testDb);
const { trip } = await seedTrip(user.id);
const bookings = await listed(user.id, { tripId: trip.id });
expect(bookings.map((b) => b.title).sort()).toEqual(['Dinner', 'Frankfurt to New York']);
const flight = bookings.find((b) => b.type === 'flight');
expect(flight).not.toHaveProperty('metadata');
expect(flight?.details).toMatchObject({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' });
expect(flight?.details.legs).toEqual([
{ from: 'FRA', to: 'MUC', flight_number: 'LH 101', dep_time: '08:15' },
expect.objectContaining({ from: 'MUC', to: 'JFK', flight_number: 'LH 410', arr_time: '13:05' }),
]);
// A provider id and a leg's own reference stay with the booking.
expect(JSON.stringify(flight?.details)).not.toContain('airtrail_ids');
expect(JSON.stringify(flight?.details)).not.toContain('LEG-REF');
expect(bookings.find((b) => b.type === 'restaurant')?.details).toEqual({ price: '80' });
});
it('RESREAD-MCP-005: says that a leg holds a reference, never the reference itself', async () => {
const { user } = createUser(testDb);
const { trip } = await seedTrip(user.id);
const bookings = await listed(user.id, { tripId: trip.id });
expect(bookings.find((b) => b.type === 'flight')?.details.has_leg_reference).toBe(true);
expect(bookings.find((b) => b.type === 'restaurant')?.details).not.toHaveProperty('has_leg_reference');
});
it('RESREAD-MCP-002: keeps one kind, or the bookings on one day (a leg arriving on it counts)', async () => {
const { user } = createUser(testDb);
const { trip, day1, day2 } = await seedTrip(user.id);
expect((await listed(user.id, { tripId: trip.id, kinds: ['transport'] })).map((b) => b.type)).toEqual(['flight']);
expect((await listed(user.id, { tripId: trip.id, kinds: ['booking'] })).map((b) => b.type)).toEqual(['restaurant']);
expect((await listed(user.id, { tripId: trip.id, dayId: day1.id })).map((b) => b.type)).toEqual(['flight']);
expect((await listed(user.id, { tripId: trip.id, dayId: day2.id })).map((b) => b.type).sort()).toEqual([
'flight',
'restaurant',
]);
});
it('RESREAD-MCP-003: refuses a trip the user cannot see', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { trip } = await seedTrip(user.id);
await withScopedHarness(stranger.id, async (h) => {
const result = await h.client.callTool({ name: 'list_reservations', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('RESREAD-MCP-004: rides reservations:read and is hidden from a token without it', async () => {
const { user } = createUser(testDb);
await withScopedHarness(
user.id,
async (h) => {
expect((await h.client.listTools()).tools.map((t) => t.name)).toContain('list_reservations');
},
['reservations:read'],
);
await withScopedHarness(
user.id,
async (h) => {
expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_reservations');
},
['places:read'],
);
});
});
describe('Tool: list_accommodations', () => {
it('RESREAD-MCP-010: lists the stays with their place and check-in and check-out', async () => {
const { user } = createUser(testDb);
const { trip, day1, day2 } = await seedTrip(user.id);
const hotel = createPlace(testDb, trip.id, { name: 'Mercure', lat: 52.53, lng: 13.38 });
createDayAccommodation(testDb, trip.id, hotel.id, day1.id, day2.id, { check_in: '15:00', confirmation: 'H-1' });
await withScopedHarness(user.id, async (h) => {
const data = parseToolResult(
await h.client.callTool({ name: 'list_accommodations', arguments: { tripId: trip.id } }),
) as { accommodations: Array<Record<string, unknown>> };
expect(data.accommodations).toHaveLength(1);
expect(data.accommodations[0]).toMatchObject({
place_id: hotel.id,
place_name: 'Mercure',
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
});
});
});
it('RESREAD-MCP-011: refuses a trip the user cannot see, and is hidden from a token without trip reads', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { trip } = await seedTrip(user.id);
await withScopedHarness(stranger.id, async (h) => {
const result = await h.client.callTool({ name: 'list_accommodations', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
await withScopedHarness(
user.id,
async (h) => {
expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_accommodations');
},
['places:read'],
);
});
});
@@ -0,0 +1,766 @@
/**
* The booking cards: a ticket, a stay or a venue from every booking read and
* write, as the planner shows them. The reference never travels, only that
* one exists; times cross zones only where both ends know theirs.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
import type { CardAirport, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
import {
answerCardOf,
appliedCardOf,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
CARD_PROMPT_LINES,
} from '../../../../../../src/nest/assistant/cards/registry';
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { MAX_CARD_CHARS, trekCardSchema, type BookingCard, type BookingItem, type TrekCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
const FLIGHT = {
id: 501,
trip_id: 1,
type: 'flight',
status: 'confirmed',
title: 'Frankfurt to New York',
day_id: 31,
end_day_id: 31,
reservation_time: '2026-06-03T08:15',
reservation_end_time: '2026-06-03T11:05',
confirmation_number: 'ABC123',
needs_review: 0,
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A', price: '420' }),
endpoints: [
{
role: 'from',
sequence: 0,
name: 'Frankfurt',
code: 'fra',
lat: 50.03,
lng: 8.57,
timezone: 'Europe/Berlin',
local_time: '08:15',
local_date: '2026-06-03',
},
{
role: 'to',
sequence: 1,
name: 'New York',
code: 'JFK',
lat: 40.64,
lng: -73.78,
timezone: 'America/New_York',
local_time: '11:05',
local_date: '2026-06-03',
},
],
travelers: [{ user_id: 7, username: 'mia', avatar: 'mia.png', avatar_url: 'https://cdn.example/mia.png' }],
};
const HOTEL = {
id: 502,
trip_id: 1,
type: 'hotel',
status: 'pending',
title: 'Mercure stay',
accommodation_id: '601.0',
accommodation_place_id: 12,
accommodation_start_day_id: 31,
accommodation_end_day_id: 33,
needs_review: 1,
metadata: null,
endpoints: [],
travelers: [],
};
const DINNER = {
id: 503,
trip_id: 1,
type: 'restaurant',
status: 'pending',
title: 'Dinner at Borchardt',
day_id: 32,
reservation_time: '2026-06-04T19:00',
reservation_end_time: '2026-06-04T21:30',
assignment_id: 301,
location: null,
metadata: '{"price":"0"}',
endpoints: [],
travelers: [],
};
const STAY = {
id: 601,
trip_id: 1,
place_id: 12,
start_day_id: 31,
end_day_id: 33,
check_in: '15:00',
check_in_end: '20:00',
check_out: '11:00',
confirmation: 'HOTEL-XYZ',
place_name: 'Mercure Hotel Berlin City',
place_address: 'Invalidenstraße 38',
place_image: '/uploads/places/mercure.jpg',
place_lat: 52.53,
place_lng: 13.38,
reservation_title: 'Mercure stay',
};
const SUMMARY = {
trip: { title: 'Berlin', currency: 'EUR' },
members: {
owner: { id: 7, username: 'mia', avatar_url: '/uploads/avatars/mia.png' },
collaborators: [{ id: 8, username: 'jon', avatar_url: null }],
},
days: [
{ id: 31, day_number: 1, date: '2026-06-03', assignments: [], notes: [] },
{
id: 32,
day_number: 2,
date: '2026-06-04',
assignments: [{ id: 301, place: { id: 13, name: 'Borchardt', lat: 52.51, lng: 13.39 } }],
notes: [],
},
{ id: 33, day_number: 3, date: '2026-06-05', assignments: [], notes: [] },
],
reservations: [FLIGHT, HOTEL, DINNER],
accommodations: [STAY],
};
const PLACES = {
places: [
{
id: 12,
name: 'Mercure Hotel Berlin City',
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
google_place_id: 'ChIJmercure',
image_url: 'https://maps.example/photo.jpg',
},
{ id: 13, name: 'Borchardt', lat: 52.51, lng: 13.39 },
],
};
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
const AIRPORTS: Record<string, CardAirport> = {
MUC: { city: 'Munich', lat: 48.35, lng: 11.79, tz: 'Europe/Berlin' },
LIS: { city: 'Lisbon', lat: 38.77, lng: -9.13, tz: 'Europe/Lisbon' },
};
const ctx: PreviewContext = {
card,
categories: new Map([
[2, 'Food'],
[5, 'Hotel'],
]),
locale: 'en-GB',
today: '2026-05-01',
airport: (code) => AIRPORTS[code] ?? null,
};
/** The card checked against the union, as the registry checks it before it streams. */
function parsed(value: TrekCard | null | undefined): BookingCard {
const result = trekCardSchema.safeParse(value);
expect(result.success).toBe(true);
expect(value?.kind).toBe('booking');
return value as BookingCard;
}
function answer(tool: string, value: unknown): BookingCard {
return parsed(answerCardOf(tool, { toolCallId: 'tc-r', args: {}, value, card, today: '2026-05-01' }));
}
function changeCard(tool: string, args: Record<string, unknown>, context: PreviewContext = ctx) {
const preview = describeToolCall(tool, 'tc-w', args, context);
return { preview, card: preview.card ?? null };
}
function first(value: BookingCard): BookingItem {
return value.items[0] as BookingItem;
}
/** Nothing secret or remote anywhere in a card's JSON. */
function expectClean(value: unknown): void {
const json = JSON.stringify(value);
for (const secret of ['ABC123', 'HOTEL-XYZ', 'LEG-REF', 'FILE-REF', 'http', '/uploads']) {
expect(json).not.toContain(secret);
}
}
describe('booking answer cards', () => {
it('ASTBOOK-001: a flight is a ticket with codes, times, carrier, seat, price and 8 h 50 across zones', () => {
const list = answer('list_reservations', { reservations: [FLIGHT, HOTEL, DINNER] });
expect(list).toMatchObject({ kind: 'booking', change: 'none', more: 0 });
expect(list.items).toHaveLength(3);
const flight = first(list);
expect(flight).toMatchObject({
reservationId: 501,
type: 'flight',
status: 'confirmed',
hasReference: true,
price: { amount: 420, currency: 'EUR' },
travelers: [{ id: 7, name: 'mia', avatarKey: 'mia.png' }],
});
expect(flight.body).toMatchObject({
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA' },
{ name: 'New York', code: 'JFK' },
],
legs: [
{
carrier: 'Lufthansa',
number: 'LH 400',
departs: '08:15',
arrives: '11:05',
departsDay: { id: 31, number: 1, date: '2026-06-03' },
minutes: 530,
seat: '12A',
},
],
minutes: 530,
mappable: true,
});
expectClean(list);
});
it('ASTBOOK-002: a hotel is a stay with its nights, check-in window, check-out and the place photo key', () => {
const stay = answer('list_reservations', { reservations: [HOTEL] });
expect(first(stay)).toMatchObject({ type: 'hotel', status: 'pending', needsReview: true, hasReference: true });
expect(first(stay).body).toEqual({
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
photoKey: 'ChIJmercure',
checkIn: { day: { id: 31, number: 1, date: '2026-06-03' }, from: '15:00', until: '20:00' },
checkOut: { day: { id: 33, number: 3, date: '2026-06-05' }, by: '11:00' },
nights: 2,
});
const listed = answer('list_accommodations', { accommodations: [STAY, { ...STAY, reservation_title: 'Twice' }] });
expect(listed.items).toHaveLength(1);
expect(first(listed)).toMatchObject({ title: 'Mercure stay', reservationId: 502, status: 'pending' });
expectClean([stay, listed]);
});
it('ASTBOOK-003: a table is a venue on its day with its time range and the planned place', () => {
const dinner = answer('list_reservations', { reservations: [DINNER] });
expect(first(dinner)).toMatchObject({ price: null, hasReference: false, needsReview: false });
expect(first(dinner).body).toEqual({
shape: 'venue',
day: { id: 32, number: 2, date: '2026-06-04' },
starts: '19:00',
ends: '21:30',
location: 'Borchardt',
placeId: 13,
lat: 52.51,
lng: 13.39,
});
});
it('ASTBOOK-004: without zones there is no duration; legs line up with the stops or fall back to one', () => {
const unplaced = (name: string, code: string, sequence: number, time: string) => ({
role: sequence === 0 ? 'from' : 'to',
sequence,
name,
code,
local_time: time,
local_date: '2026-06-03',
});
const bare = {
...FLIGHT,
endpoints: [unplaced('Frankfurt', 'FRA', 0, '08:15'), unplaced('New York', 'JFK', 1, '11:05')],
};
const noZone = first(answer('list_reservations', { reservations: [bare] }));
expect(noZone.body).toMatchObject({ minutes: null, mappable: false, legs: [{ minutes: null }] });
const stop = { role: 'stop', sequence: 1, name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.79 };
const via = [FLIGHT.endpoints[0], stop, { ...FLIGHT.endpoints[1], sequence: 2 }];
const legs = [
{ flight_number: 'LH 101', dep_day_id: 31, dep_time: '08:15', arr_day_id: 31, arr_time: '09:10' },
{ flight_number: 'LH 410', dep_day_id: 31, dep_time: '10:30', arr_day_id: 31, arr_time: '13:05' },
];
const twoLegs = { ...FLIGHT, endpoints: via, details: { legs } };
const multi = first(answer('list_reservations', { reservations: [twoLegs] })).body;
expect(multi).toMatchObject({
legs: [
{ number: 'LH 101', departs: '08:15', arrives: '09:10', minutes: 55 },
{ number: 'LH 410', departs: '10:30', arrives: '13:05' },
],
});
const mismatch = { ...twoLegs, details: { legs: legs.slice(0, 1) } };
expect(first(answer('list_reservations', { reservations: [mismatch] })).body).toMatchObject({
legs: [{ departs: '08:15', arrives: '11:05' }],
});
});
it('ASTBOOK-005: a train keeps number, platform and seat; a hire car without a route is one leg', () => {
const train = {
...FLIGHT,
id: 504,
type: 'train',
endpoints: [],
details: { train_number: 'ICE 597', platform: '7', seat: '41' },
};
expect(first(answer('list_reservations', { reservations: [train] })).body).toMatchObject({
stops: [],
legs: [{ number: 'ICE 597', platform: '7', seat: '41', departs: '08:15', arrives: '11:05' }],
mappable: false,
});
const car = { id: 505, trip_id: 1, type: 'car', title: 'Hire car', reservation_time: '09:00', endpoints: [] };
expect(first(answer('list_reservations', { reservations: [car] })).body).toMatchObject({
legs: [{ departs: '09:00', arrives: null, departsDay: null, carrier: null }],
});
});
it('ASTBOOK-006: a price reads only when it is a positive amount; an odd currency takes the trip one', () => {
const priced = (metadata: unknown) =>
first(answer('list_reservations', { reservations: [{ ...DINNER, metadata: JSON.stringify(metadata) }] })).price;
expect(priced({ price: 'abc' })).toBeNull();
expect(priced({ price: '0' })).toBeNull();
expect(priced({ price: 12.5, priceCurrency: 'usd' })).toEqual({ amount: 12.5, currency: 'EUR' });
expect(priced({ price: '30', priceCurrency: 'CHF' })).toEqual({ amount: 30, currency: 'CHF' });
});
it('ASTBOOK-007: an upcoming row of another trip names that trip; a check-in is a stay; no picture travels', () => {
const upcoming = answer('list_upcoming_reservations', {
reservations: [
{
id: 900,
trip_id: 2,
title: 'Night train',
type: 'train',
status: 'confirmed',
reservation_time: '2026-07-01T22:10',
confirmation_number: 'ABC123',
trip_title: 'Vienna',
trip_cover: '/uploads/covers/vienna.jpg',
day_date: '2026-07-01',
place_name: null,
place_image: 'https://maps.example/x.jpg',
},
{
id: 601,
trip_id: 1,
title: 'Mercure stay',
type: 'checkin',
status: null,
reservation_time: '15:00',
trip_title: 'Berlin',
day_date: '2026-06-03',
place_name: 'Mercure Hotel Berlin City',
},
],
});
const [train, checkIn] = upcoming.items as [BookingItem, BookingItem];
expect(train).toMatchObject({ tripId: 2, tripTitle: 'Vienna', reservationId: 900, hasReference: true });
expect(train.body).toMatchObject({ legs: [{ departs: '22:10', departsDay: { id: null, date: '2026-07-01' } }] });
expect(checkIn).toMatchObject({ tripId: 1, tripTitle: null, type: 'hotel', status: null, reservationId: null });
expect(checkIn.body).toMatchObject({
shape: 'stay',
accommodationId: 601,
checkIn: { day: { id: 31, date: '2026-06-03' }, from: '15:00' },
checkOut: null,
});
expectClean(upcoming);
});
it('ASTBOOK-008: ten bookings show eight and count the rest, well under the card size cap', () => {
const many = Array.from({ length: 10 }, (_, i) => ({ ...FLIGHT, id: 700 + i }));
const list = answer('list_reservations', { reservations: many });
expect(list.items).toHaveLength(8);
expect(list.more).toBe(2);
expect(JSON.stringify(list).length).toBeLessThan(MAX_CARD_CHARS);
});
it('ASTBOOK-009: a reference kept only on a leg still counts, and a stay reads a review flag stored as true', () => {
const legRef = {
...FLIGHT,
confirmation_number: null,
metadata: undefined,
details: { airline: 'Lufthansa', has_leg_reference: true },
};
expect(first(answer('list_reservations', { reservations: [legRef] })).hasReference).toBe(true);
const flagged = { ...SUMMARY, reservations: [FLIGHT, { ...HOTEL, needs_review: true }, DINNER] };
const flaggedCard = buildTripCard(1, 7, flagged, PLACES, null);
const stays = parsed(
answerCardOf('list_accommodations', {
toolCallId: 'tc-s',
args: {},
value: { accommodations: [STAY] },
card: flaggedCard,
today: '2026-05-01',
}),
);
expect(first(stays)).toMatchObject({ reservationId: 502, needsReview: true });
});
});
describe('booking change cards', () => {
it('ASTBOOK-010: a transport places code-only stops at their airports and names its own action', () => {
const { preview, card: drawn } = changeCard('create_transport', {
type: 'flight',
title: 'Munich to Lisbon',
start_day_id: 32,
endpoints: [
{ role: 'from', sequence: 0, name: 'Munich', code: 'MUC', local_time: '07:00' },
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS', local_time: '09:10' },
],
confirmation_number: 'ABC123',
price: 99,
});
expect(preview).toMatchObject({ verbKey: 'addReservation', tool: 'create_transport', subject: 'Munich to Lisbon' });
const item = first(parsed(drawn));
expect(parsed(drawn).change).toBe('create');
expect(item).toMatchObject({ status: 'pending', reservationId: null, hasReference: true, price: { amount: 99 } });
expect(item.body).toMatchObject({
stops: [
{ code: 'MUC', lat: 48.35, lng: 11.79 },
{ code: 'LIS', lat: 38.77, lng: -9.13 },
],
legs: [{ departs: '07:00', arrives: '09:10', departsDay: { id: 32 }, minutes: 190 }],
mappable: false,
});
const unknown = changeCard('create_transport', {
type: 'flight',
title: 'Somewhere',
endpoints: [
{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' },
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS' },
],
});
expect(first(parsed(unknown.card)).body).toMatchObject({ stops: [{ lat: null, lng: null }, { lat: 38.77 }] });
expectClean([preview, drawn]);
});
it('ASTBOOK-011: a hotel booking with a place and both days draws the stay and puts it on its check-in day', () => {
const { preview, card: drawn } = changeCard('create_reservation', {
title: 'Mercure',
type: 'hotel',
place_id: 12,
start_day_id: 31,
end_day_id: 33,
check_in: '15:00',
check_out: '11:00',
});
expect(preview.effect).toEqual({
kind: 'add',
dayId: 31,
fromDayId: null,
placeId: 12,
assignmentId: null,
place: null,
});
expect(first(parsed(drawn)).body).toMatchObject({
shape: 'stay',
placeId: 12,
photoKey: 'ChIJmercure',
checkIn: { day: { id: 31 }, from: '15:00' },
checkOut: { day: { id: 33 }, by: '11:00' },
nights: 2,
});
const table = changeCard('create_reservation', { title: 'Table', type: 'restaurant', assignment_id: 301 });
expect(table.preview.effect).toBeNull();
expect(first(parsed(table.card)).body).toMatchObject({ shape: 'venue', placeId: 13, location: 'Borchardt' });
});
it('ASTBOOK-012: an update marks only the fields whose card value changes; an unknown booking draws none', () => {
const { preview, card: drawn } = changeCard('update_reservation', { reservationId: 503, status: 'confirmed' });
expect(preview).toMatchObject({ verbKey: 'updateReservation', tool: 'update_reservation' });
expect(preview.rows).toEqual([
{ op: 'change', labelKey: 'field', field: 'status', before: 'pending', after: 'confirmed' },
]);
expect(parsed(drawn)).toMatchObject({ change: 'update' });
expect(first(parsed(drawn))).toMatchObject({ status: 'confirmed', changed: ['status'] });
const same = changeCard('update_reservation', { reservationId: 503, title: 'Dinner at Borchardt' });
expect(first(parsed(same.card)).changed).toEqual([]);
const moved = changeCard('update_transport', {
reservationId: 501,
endpoints: [FLIGHT.endpoints[0], { role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS' }],
});
expect(first(parsed(moved.card)).changed).toEqual(expect.arrayContaining(['route']));
expect(first(parsed(moved.card)).changed).not.toContain('status');
expect(changeCard('update_reservation', { reservationId: 999, status: 'confirmed' }).card).toBeNull();
});
it('ASTBOOK-013: a removal draws the booking as it is; new travellers leave out who is not on the trip', () => {
expect(parsed(changeCard('delete_transport', { reservationId: 501 }).card)).toMatchObject({ change: 'remove' });
expect(parsed(changeCard('delete_accommodation', { accommodationId: 601 }).card).change).toBe('remove');
const { preview, card: drawn } = changeCard('set_reservation_travelers', { reservationId: 503, user_ids: [8, 99] });
expect(preview).toMatchObject({ verbKey: 'updateReservation', tool: 'set_reservation_travelers' });
expect(first(parsed(drawn))).toMatchObject({
travelers: [{ id: 8, name: 'jon', avatarKey: null }],
changed: ['travelers'],
});
});
it('ASTBOOK-014: a stay names its verb, shows its place and lands on its check-in day', () => {
const { preview, card: drawn } = changeCard('create_accommodation', {
place_id: 12,
start_day_id: 31,
end_day_id: 32,
check_in: '14:00',
confirmation: 'HOTEL-XYZ',
});
expect(preview).toMatchObject({ verbKey: 'addStay', tool: 'create_accommodation', undoable: true });
expect(preview.effect).toMatchObject({ kind: 'add', dayId: 31, placeId: 12 });
expect(first(parsed(drawn))).toMatchObject({
title: 'Mercure Hotel Berlin City',
status: null,
hasReference: true,
});
expect(changeCard('create_accommodation', { place_id: 404, start_day_id: 31, end_day_id: 32 }).card).toBeNull();
const added = changeCard('create_place_accommodation', {
name: 'Hotel Adlon',
address: 'Unter den Linden 77',
lat: 52.516,
lng: 13.38,
google_place_id: 'ChIJadlon',
start_day_id: 32,
end_day_id: 33,
price: 840,
currency: 'eur',
});
expect(added.preview.verbKey).toBe('addStay');
expect(added.preview.effect).toMatchObject({
kind: 'add',
dayId: 32,
place: { name: 'Hotel Adlon', lat: 52.516, lng: 13.38, categoryId: 5 },
});
expect(first(parsed(added.card))).toMatchObject({ price: { amount: 840, currency: 'EUR' } });
expect(first(parsed(added.card)).body).toMatchObject({ placeId: null, photoKey: 'ChIJadlon', nights: 1 });
// Without a position there is no pin, so no stop is previewed on the day.
const unplaced = changeCard('create_place_accommodation', { name: 'Somewhere', start_day_id: 32, end_day_id: 33 });
expect(unplaced.preview.effect).toBeNull();
});
it('ASTBOOK-015: moving a stay to another check-in day moves its stop; linking a hotel marks the stay', () => {
const { preview, card: drawn } = changeCard('update_accommodation', { accommodationId: 601, start_day_id: 32 });
expect(preview).toMatchObject({ verbKey: 'updateStay', subject: 'Mercure Hotel Berlin City' });
expect(preview.effect).toMatchObject({ kind: 'move', dayId: 32, fromDayId: 31, placeId: 12 });
expect(first(parsed(drawn)).changed).toContain('stay');
const linked = changeCard('link_hotel_accommodation', {
reservationId: 502,
place_id: 12,
start_day_id: 31,
end_day_id: 32,
check_in: '16:00',
});
expect(linked.preview).toMatchObject({ verbKey: 'updateStay', tool: 'link_hotel_accommodation' });
expect(first(parsed(linked.card)).changed).toEqual(expect.arrayContaining(['stay', 'checkIn']));
});
it('ASTBOOK-016: a booking read from a file is drawn with its stay nights by date and no reference', () => {
const { preview, card: drawn } = changeCard('import_booking', {
type: 'hotel',
title: 'Mercure Hotel Berlin City',
confirmation_number: 'FILE-REF',
_accommodation: { check_in: '2026-06-03T15:00', check_out: '2026-06-05T11:00', confirmation: 'FILE-REF' },
_venue: { name: 'Mercure', address: 'Invalidenstraße 38', lat: 52.53, lng: 13.38 },
source: { fileName: 'mercure.pdf', index: 0 },
});
expect(preview.verbKey).toBe('importBooking');
expect(first(parsed(drawn))).toMatchObject({ hasReference: true, status: null });
expect(first(parsed(drawn)).body).toMatchObject({
checkIn: { day: { id: 31 }, from: '15:00' },
checkOut: { day: { id: 33 }, by: '11:00' },
nights: 2,
lat: 52.53,
});
expect(JSON.stringify(drawn)).not.toContain('FILE-REF');
// Applied, the import answers the id alone: the card is the preview's, saved under that id.
const slim = { reservation: { id: 502, title: 'Mercure Hotel Berlin City', type: 'hotel', day_id: null } };
const saved = parsed(appliedCardOf('import_booking', { args: {}, result: slim, preview }));
expect(saved.change).toBe('none');
expect(first(saved)).toMatchObject({ reservationId: 502, hasReference: true, changed: [] });
expect(first(saved).body).toMatchObject({
checkIn: { day: { id: 31 }, from: '15:00' },
checkOut: { day: { id: 33 }, by: '11:00' },
nights: 2,
});
expect(appliedCardOf('import_booking', { args: {}, result: 'Not added.', preview })).toBeNull();
});
it('ASTBOOK-018: an applied flight from a file keeps its route, times and price, and can show it on the map', () => {
const args = {
type: 'flight',
title: 'Frankfurt to New York',
reservation_time: '2026-06-03T08:15',
reservation_end_time: '2026-06-03T11:05',
confirmation_number: 'FILE-REF',
metadata: { airline: 'Lufthansa', flight_number: 'LH 400', price: '420' },
endpoints: FLIGHT.endpoints,
source: { fileName: 'lh400.pdf', index: 0 },
};
const { preview, card: drawn } = changeCard('import_booking', args);
expect(first(parsed(drawn)).body).toMatchObject({ mappable: false });
const slim = { reservation: { id: 520, title: args.title, type: 'flight', day_id: 31 } };
const saved = first(parsed(appliedCardOf('import_booking', { args: {}, result: slim, preview })));
expect(saved).toMatchObject({ reservationId: 520, hasReference: true, price: { amount: 420, currency: 'EUR' } });
expect(saved.body).toMatchObject({
stops: [{ code: 'FRA' }, { code: 'JFK' }],
legs: [{ departs: '08:15', arrives: '11:05', carrier: 'Lufthansa' }],
minutes: 530,
mappable: true,
});
expectClean(saved);
});
it('ASTBOOK-019: a hotel changed into another type reads as that type, not as the stay it still links', () => {
const { card: drawn } = changeCard('update_reservation', { reservationId: 502, type: 'restaurant' });
const item = first(parsed(drawn));
expect(item.body.shape).toBe('venue');
expect(item.changed).toEqual(expect.arrayContaining(['type']));
const preview = describeToolCall('update_reservation', 'tc-w', { reservationId: 502, type: 'restaurant' }, ctx);
const result = { reservation: { ...HOTEL, type: 'restaurant' } };
const saved = parsed(appliedCardOf('update_reservation', { args: {}, result, preview }));
expect(first(saved).body.shape).toBe('venue');
});
it('ASTBOOK-017: every curated booking write describes empty arguments with its own verb', () => {
for (const tool of [
'create_transport',
'update_reservation',
'update_transport',
'set_reservation_travelers',
'link_hotel_accommodation',
'create_accommodation',
'create_place_accommodation',
'update_accommodation',
]) {
expect(changeCard(tool, {}).preview.verbKey, tool).toBe(ASSISTANT_WRITE_TOOLS[tool].verbKey);
}
});
});
describe('applied booking cards and receipts', () => {
const previewOf = (tool: string, args: Record<string, unknown>) => describeToolCall(tool, 'tc-w', args, ctx);
it('ASTBOOK-020: an applied booking is the saved row, its days named from the preview', () => {
const args = { type: 'flight', title: 'Frankfurt to New York', start_day_id: 31, endpoints: FLIGHT.endpoints };
const preview = previewOf('create_transport', args);
const saved = parsed(appliedCardOf('create_transport', { args, result: { reservation: FLIGHT }, preview }));
expect(saved.change).toBe('none');
expect(first(saved)).toMatchObject({ reservationId: 501, status: 'confirmed', hasReference: true });
expect(first(saved).body).toMatchObject({ mappable: true, legs: [{ departsDay: { id: 31, number: 1 } }] });
expectClean(saved);
expect(appliedCardOf('create_transport', { args, result: 'Transport not found.', preview })).toBeNull();
});
it('ASTBOOK-021: an applied stay keeps the times and photo the preview showed; travellers are replaced', () => {
const args = {
name: 'Hotel Adlon',
google_place_id: 'ChIJadlon',
start_day_id: 32,
end_day_id: 33,
check_in: '15:00',
price: 840,
currency: 'EUR',
};
const preview = previewOf('create_place_accommodation', args);
const result = {
place: {
id: 77,
name: 'Hotel Adlon',
google_place_id: 'ChIJadlon',
lat: 52.516,
lng: 13.38,
price: 840,
currency: 'EUR',
},
accommodation: { id: 602, trip_id: 1, place_id: 77, start_day_id: 32, end_day_id: 33, check_in: null },
assignment: { id: 9 },
};
const saved = parsed(appliedCardOf('create_place_accommodation', { args, result, preview }));
// The price lives on the place the stay was created with.
expect(first(saved).price).toEqual({ amount: 840, currency: 'EUR' });
expect(first(saved).body).toMatchObject({
accommodationId: 602,
placeId: 77,
photoKey: 'ChIJadlon',
checkIn: { day: { id: 32, number: 2 }, from: '15:00' },
});
const travelers = previewOf('set_reservation_travelers', { reservationId: 503, user_ids: [8] });
const set = parsed(
appliedCardOf('set_reservation_travelers', {
args: {},
result: { travelers: [{ user_id: 7, username: 'mia', avatar: null }] },
preview: travelers,
}),
);
expect(first(set).travelers).toEqual([{ id: 7, name: 'mia', avatarKey: null }]);
});
it('ASTBOOK-022: a stay write files its receipt under the stay, a travellers write under the booking', () => {
const result = { accommodation: { id: 601 }, assignment: { id: 9 }, place: { id: 12 } };
expect(appliedChange('create_place_accommodation', 'tc-1', {}, result, 31, null)).toMatchObject({
entity: 'accommodation',
ids: [601],
});
expect(appliedChange('update_accommodation', 'tc-2', { accommodationId: 601 }, result, null, null)).toMatchObject({
entity: 'accommodation',
ids: [601],
});
expect(appliedChange('set_reservation_travelers', 'tc-3', { reservationId: 503 }, {}, null, null)).toMatchObject({
entity: 'reservation',
ids: [503],
});
expect(appliedChange('create_transport', 'tc-4', {}, { reservation: FLIGHT }, null, null)).toMatchObject({
entity: 'reservation',
ids: [501],
dayId: 31,
});
});
});
describe('booking undo, naming and prompt', () => {
const snapshot = (summary: Record<string, unknown> = SUMMARY, places: unknown = PLACES) => ({ summary, places });
it('ASTBOOK-030: a created booking or stay is taken back by its own removal', () => {
expect(ASSISTANT_UNDO_MAP.create_reservation.args({}, { reservation: { id: 503 } }, null)).toEqual({
reservationId: 503,
});
expect(ASSISTANT_UNDO_MAP.create_transport.tool).toBe('delete_transport');
expect(ASSISTANT_UNDO_MAP.create_accommodation.args({}, { accommodation: { id: 601 } }, null)).toEqual({
accommodationId: 601,
});
expect(ASSISTANT_UNDO_MAP.create_place_accommodation.args({}, { place: { id: 12 } }, null)).toEqual({
placeId: 12,
});
expect(ASSISTANT_UNDO_MAP.create_accommodation.args({}, 'Failed.', null)).toBeNull();
});
it('ASTBOOK-031: an undo holds while the booking or stay is as created, and stops once it changed', () => {
expect(unchangedSinceApplied('create_reservation', { reservation: DINNER }, snapshot())).toBe(true);
const confirmed = { ...SUMMARY, reservations: [FLIGHT, HOTEL, { ...DINNER, status: 'confirmed' }] };
expect(unchangedSinceApplied('create_reservation', { reservation: DINNER }, snapshot(confirmed))).toBe(false);
expect(unchangedSinceApplied('create_accommodation', { accommodation: STAY }, snapshot())).toBe(true);
const later = { ...SUMMARY, accommodations: [{ ...STAY, check_in: '18:00' }] };
expect(unchangedSinceApplied('create_accommodation', { accommodation: STAY }, snapshot(later))).toBe(false);
});
it('ASTBOOK-032: a place created with its stay can go while only that stay and its booking use it', () => {
const place = { id: 12, name: 'Mercure Hotel Berlin City', lat: 52.53, lng: 13.38 };
const result = { place, accommodation: STAY, assignment: { id: 301 } };
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot())).toBe(true);
const booked = { ...SUMMARY, reservations: [...SUMMARY.reservations, { id: 510, place_id: 12, title: 'Spa' }] };
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot(booked))).toBe(false);
const planned = {
...SUMMARY,
days: [{ id: 33, day_number: 3, assignments: [{ id: 302, day_id: 33, place_id: 12 }] }],
};
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot(planned))).toBe(false);
});
it('ASTBOOK-033: a generic stay removal names the stay; the prompt tells the model to leave the cards alone', () => {
const preview = describeToolCall('delete_accommodation', 'tc-9', { accommodationId: 601 }, ctx);
expect(JSON.stringify(preview.rows)).toContain('Mercure Hotel Berlin City');
expect(CARD_PROMPT_LINES.some((line) => line.includes('list_reservations'))).toBe(true);
expect(CARD_PROMPT_LINES.some((line) => line.includes('create_place_accommodation'))).toBe(true);
});
});
@@ -1,4 +1,9 @@
import { localParts, resolveTimeZone } from '../../../../src/nest/common/timezoneService';
import {
localParts,
offsetMinutesAt,
resolveTimeZone,
wallClockInstant,
} from '../../../../src/nest/common/timezoneService';
import { describe, expect, it } from 'vitest';
@@ -14,4 +19,31 @@ describe('timezoneService', () => {
expect(localParts('invalid', 'Asia/Tokyo')).toEqual({ date: null, time: null });
expect(localParts(null, null)).toEqual({ date: null, time: null });
});
it('TZSVC-001: reads a zone offset in minutes east of UTC, summer and winter', () => {
expect(offsetMinutesAt('Europe/Berlin', Date.UTC(2026, 6, 1))).toBe(120);
expect(offsetMinutesAt('Europe/Berlin', Date.UTC(2026, 0, 1))).toBe(60);
expect(offsetMinutesAt('America/St_Johns', Date.UTC(2026, 0, 1))).toBe(-210);
expect(offsetMinutesAt('UTC', Date.UTC(2026, 0, 1))).toBe(0);
});
it('TZSVC-002: turns a wall clock in a zone into its instant', () => {
expect(wallClockInstant('2026-06-03', '08:15', 'Europe/Berlin')).toBe(Date.UTC(2026, 5, 3, 6, 15));
expect(wallClockInstant('2026-06-03', '11:05', 'America/New_York')).toBe(Date.UTC(2026, 5, 3, 15, 5));
// On the day the clocks go back, a midday clock already reads winter time.
expect(wallClockInstant('2026-10-25', '12:00', 'Europe/Berlin')).toBe(Date.UTC(2026, 9, 25, 11, 0));
});
it('TZSVC-003: refuses a malformed date, a malformed time and a zone that does not exist', () => {
expect(wallClockInstant('2026-6-3', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-06-03', '24:00', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-06-03', '08:15', 'Mars/Olympus')).toBeNull();
});
it('TZSVC-004: refuses a day the month does not have instead of rolling it over', () => {
expect(wallClockInstant('2026-02-31', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-04-31', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2026-13-01', '08:15', 'Europe/Berlin')).toBeNull();
expect(wallClockInstant('2028-02-29', '08:15', 'UTC')).toBe(Date.UTC(2028, 1, 29, 8, 15));
});
});
@@ -0,0 +1,125 @@
import {
BOOKING_CHANGED_FIELDS,
bookingCardSchema,
type BookingItem,
type BookingLeg,
type BookingStay,
type BookingTicket,
} from './booking.card';
import { BOOKING_APPLIED_ENTITIES, BOOKING_VERB_KEYS } from './booking.contract';
import { storedTrekCardSchema, trekCardSchema } from './index';
import { describe, expect, it } from 'vitest';
const day = { id: 31, number: 1, date: '2026-06-03' };
const LEG: BookingLeg = {
carrier: 'Lufthansa',
number: 'LH 400',
departs: '08:15',
departsDay: day,
arrives: '11:05',
arrivesDay: day,
minutes: 530,
seat: '12A',
platform: null,
};
const TICKET: BookingTicket = {
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57 },
{ name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 },
],
legs: [LEG],
minutes: 530,
mappable: true,
};
const STAY: BookingStay = {
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
photoKey: 'ChIJmercure',
checkIn: { day, from: '15:00', until: '20:00' },
checkOut: { day: { id: 33, number: 3, date: '2026-06-05' }, by: '11:00' },
nights: 2,
};
const ticket: BookingItem = {
tripId: 4,
tripTitle: null,
reservationId: 501,
type: 'flight',
title: 'Frankfurt to New York',
status: 'confirmed',
needsReview: false,
hasReference: true,
price: { amount: 420, currency: 'EUR' },
travelers: [{ id: 7, name: 'mia', avatarKey: 'mia.png' }],
moreTravelers: 0,
changed: [],
body: TICKET,
};
const stay: BookingItem = {
...ticket,
reservationId: null,
type: 'hotel',
status: null,
body: STAY,
};
const card = (items: unknown[], extra: Record<string, unknown> = {}) => ({
kind: 'booking',
change: 'none',
items,
more: 0,
...extra,
});
describe('bookingCardSchema', () => {
it('CARDBOOK-001: takes a ticket, a stay and a venue in one card, and the union reads it', () => {
const body = { shape: 'venue', day, starts: '19:00', ends: null, location: 'Borchardt', placeId: 13 };
const venue = { ...ticket, body: { ...body, lat: null, lng: null } };
expect(bookingCardSchema.safeParse(card([ticket, stay, venue])).success).toBe(true);
expect(trekCardSchema.safeParse(card([ticket], { change: 'update' })).success).toBe(true);
});
it('CARDBOOK-002: holds one to eight bookings and counts the rest', () => {
expect(bookingCardSchema.safeParse(card([])).success).toBe(false);
expect(bookingCardSchema.safeParse(card(Array.from({ length: 9 }, () => ticket))).success).toBe(false);
expect(bookingCardSchema.safeParse(card([ticket], { more: -1 })).success).toBe(false);
});
it('CARDBOOK-003: refuses a picture address or a coordinate key as photo key, and a clock past 23:59', () => {
const photo = (photoKey: string) => card([{ ...stay, body: { ...STAY, photoKey } }]);
expect(bookingCardSchema.safeParse(photo('https://maps.example/x.jpg')).success).toBe(false);
expect(bookingCardSchema.safeParse(photo('coords:52.5,13.4')).success).toBe(false);
const late = { ...ticket, body: { ...TICKET, legs: [{ ...LEG, departs: '24:00' }] } };
expect(bookingCardSchema.safeParse(card([late])).success).toBe(false);
});
it('CARDBOOK-004: refuses a ticket without a leg, an unknown shape, a lowercase code and a currency in words', () => {
expect(bookingCardSchema.safeParse(card([{ ...ticket, body: { ...TICKET, legs: [] } }])).success).toBe(false);
expect(bookingCardSchema.safeParse(card([{ ...ticket, body: { shape: 'cruise' } }])).success).toBe(false);
const code = { ...ticket, body: { ...TICKET, stops: [{ name: 'Frankfurt', code: 'fra', lat: null, lng: null }] } };
expect(bookingCardSchema.safeParse(card([code])).success).toBe(false);
const words = { ...ticket, price: { amount: 5, currency: 'euro' } };
expect(bookingCardSchema.safeParse(card([words])).success).toBe(false);
});
it('CARDBOOK-005: a changed field must be one the card knows; a broken card reads as none, never the message', () => {
expect(bookingCardSchema.safeParse(card([{ ...ticket, changed: [...BOOKING_CHANGED_FIELDS] }])).success).toBe(true);
expect(bookingCardSchema.safeParse(card([{ ...ticket, changed: ['seat'] }])).success).toBe(false);
expect(storedTrekCardSchema.parse(card([{ ...ticket, title: '' }]))).toBeNull();
});
it('CARDBOOK-006: the bookings add their verbs and the stay as an applied entity', () => {
expect(BOOKING_VERB_KEYS).toEqual(['addStay', 'updateStay', 'updateReservation']);
expect(BOOKING_APPLIED_ENTITIES).toEqual(['accommodation']);
});
});
+148 -3
View File
@@ -1,8 +1,153 @@
import { idSchema } from '../../common/primitives.schema';
import { RESERVATION_STATUSES } from '../../reservation/reservation-types';
import {
cardClockSchema,
cardCountSchema,
cardDaySchema,
cardLatSchema,
cardLngSchema,
cardMoneySchema,
cardPersonSchema,
cardPhotoKeySchema,
cardText,
} from './card-parts';
import { z } from 'zod';
/**
* Bookings: flights, trains, stays and other reservations. Only its kind so far; the booking cards
* give it its fields.
* Bookings in the assistant chat: a flight, a train, a hire car (a ticket), a
* hotel or any stay (a stay), a table, a show, a tour or a car park (a
* venue). One card per tool call holds the bookings it is about, at most
* eight, with the rest counted; an e-ticket file with a flight, a hotel and a
* flight back stays one itinerary. A booking's reference never travels, only
* that it has one: the chat is stored and can be shared, and the planner
* blurs it too.
*/
export const bookingCardSchema = z.object({ kind: z.literal('booking') });
/** The type column is free text: any short key passes, the client draws an unknown one as `other`. */
const bookingTypeSchema = z.string().regex(/^[a-z_]{1,30}$/);
const lat = cardLatSchema.nullable();
const lng = cardLngSchema.nullable();
/** One end or stop of a route: the airport or station, its code where it has one. */
const bookingStopSchema = z.object({
name: cardText(80),
code: z
.string()
.regex(/^[A-Z0-9]{2,5}$/)
.nullable(),
lat,
lng,
});
/** One segment; a stopover's arrival lives only here (a stop's endpoint carries the onward departure). */
const bookingLegSchema = z.object({
carrier: cardText(60).nullable(),
number: cardText(20).nullable(),
departs: cardClockSchema.nullable(),
departsDay: cardDaySchema.nullable(),
arrives: cardClockSchema.nullable(),
arrivesDay: cardDaySchema.nullable(),
/** Only when both ends know their zone. */
minutes: z.number().int().min(1).max(10_080).nullable(),
seat: cardText(20).nullable(),
platform: cardText(20).nullable(),
});
const ticketBodySchema = z.object({
shape: z.literal('ticket'),
/** From, the stops, to; empty when no route is known (a hire car, a taxi). */
stops: z.array(bookingStopSchema).max(9),
/** One per segment between the stops, else exactly one. */
legs: z.array(bookingLegSchema).min(1).max(8),
/** First departure to last arrival. */
minutes: z.number().int().min(1).max(20_160).nullable(),
/** A saved booking with two stops that have coordinates: the plan can draw it. */
mappable: z.boolean(),
});
const stayBodySchema = z.object({
shape: z.literal('stay'),
/** Null while a create or a new place waits. */
accommodationId: idSchema.nullable(),
placeId: idSchema.nullable(),
address: cardText(200).nullable(),
lat,
lng,
/** A Google place id for TREK's photo proxy; never a picture address. */
photoKey: cardPhotoKeySchema.nullable(),
checkIn: z
.object({ day: cardDaySchema, from: cardClockSchema.nullable(), until: cardClockSchema.nullable() })
.nullable(),
checkOut: z.object({ day: cardDaySchema, by: cardClockSchema.nullable() }).nullable(),
nights: z.number().int().min(0).max(366).nullable(),
});
const venueBodySchema = z.object({
shape: z.literal('venue'),
day: cardDaySchema.nullable(),
starts: cardClockSchema.nullable(),
ends: cardClockSchema.nullable(),
/** The location text, else the linked place's name. */
location: cardText(200).nullable(),
placeId: idSchema.nullable(),
lat,
lng,
});
/** What an update changes, as the card shows it (its derived values, not the raw arguments). */
export const BOOKING_CHANGED_FIELDS = [
'title',
'type',
'status',
'day',
'time',
'route',
'stay',
'checkIn',
'checkOut',
'location',
'price',
'travelers',
'reference',
] as const;
export type BookingChangedField = (typeof BOOKING_CHANGED_FIELDS)[number];
export const bookingItemSchema = z.object({
tripId: idSchema,
/** Only on a row of another trip (the upcoming bookings across every trip). */
tripTitle: cardText(120).nullable(),
reservationId: idSchema.nullable(),
type: bookingTypeSchema,
title: cardText(200),
/** Null for a transit journey, a bare stay, or a status not known. */
status: z.enum(RESERVATION_STATUSES).nullable(),
needsReview: z.boolean(),
/** A confirmation number exists; the number itself never travels. */
hasReference: z.boolean(),
price: cardMoneySchema.nullable(),
travelers: z.array(cardPersonSchema).max(8),
moreTravelers: z.number().int().min(0).max(500),
changed: z.array(z.enum(BOOKING_CHANGED_FIELDS)).max(BOOKING_CHANGED_FIELDS.length),
body: z.discriminatedUnion('shape', [ticketBodySchema, stayBodySchema, venueBodySchema]),
});
export type BookingItem = z.infer<typeof bookingItemSchema>;
export type BookingBody = BookingItem['body'];
export type BookingTicket = Extract<BookingBody, { shape: 'ticket' }>;
export type BookingStay = Extract<BookingBody, { shape: 'stay' }>;
export type BookingVenue = Extract<BookingBody, { shape: 'venue' }>;
export type BookingLeg = BookingTicket['legs'][number];
export type BookingStop = BookingTicket['stops'][number];
/** What a held write does to its bookings; `none` on an answer and on an applied change. */
export const BOOKING_CHANGES = ['none', 'create', 'update', 'remove'] as const;
export const bookingCardSchema = z.object({
kind: z.literal('booking'),
change: z.enum(BOOKING_CHANGES),
items: z.array(bookingItemSchema).min(1).max(8),
/** Bookings the answer held past the eight shown. */
more: cardCountSchema,
});
export type BookingCard = z.infer<typeof bookingCardSchema>;
@@ -10,5 +10,7 @@ export * from './booking.card';
* or entity is added here and nowhere else.
*/
export const BOOKING_CARD_SCHEMAS = [bookingCardSchema] as const;
export const BOOKING_VERB_KEYS = [] as const;
export const BOOKING_APPLIED_ENTITIES = [] as const;
/** addReservation and importBooking sit in the core list already. */
export const BOOKING_VERB_KEYS = ['addStay', 'updateStay', 'updateReservation'] as const;
/** 'reservation' is a core entity already; a stay is its own row. */
export const BOOKING_APPLIED_ENTITIES = ['accommodation'] as const;
+56 -1
View File
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'الحجوزات',
'assistant.card.booking.open': 'افتح الحجز',
'assistant.card.booking.openStay': 'اعرض في الخطة',
'assistant.card.booking.more': '{count} حجز آخر',
'assistant.card.booking.more.zero': 'لا حجوزات أخرى',
'assistant.card.booking.more.one': 'حجز آخر واحد',
'assistant.card.booking.more.two': 'حجزان آخران',
'assistant.card.booking.more.few': '{count} حجوزات أخرى',
'assistant.card.booking.more.many': '{count} حجزًا آخر',
'assistant.card.booking.nights': '{count} ليلة',
'assistant.card.booking.nights.zero': 'لا ليالٍ',
'assistant.card.booking.nights.one': 'ليلة واحدة',
'assistant.card.booking.nights.two': 'ليلتان',
'assistant.card.booking.nights.few': '{count} ليالٍ',
'assistant.card.booking.nights.many': '{count} ليلة',
'assistant.card.booking.stops': '{count} توقف',
'assistant.card.booking.stops.zero': 'بلا توقف',
'assistant.card.booking.stops.one': 'توقف واحد',
'assistant.card.booking.stops.two': 'توقفان',
'assistant.card.booking.stops.few': '{count} توقفات',
'assistant.card.booking.stops.many': '{count} توقفًا',
'assistant.card.booking.direct': 'رحلة مباشرة',
'assistant.card.booking.layoverAt': '{duration} في {place}',
'assistant.card.booking.arrivesLater': 'يصل بعد {count} يوم',
'assistant.card.booking.arrivesLater.zero': 'يصل في اليوم نفسه',
'assistant.card.booking.arrivesLater.one': 'يصل في اليوم التالي',
'assistant.card.booking.arrivesLater.two': 'يصل بعد يومين',
'assistant.card.booking.arrivesLater.few': 'يصل بعد {count} أيام',
'assistant.card.booking.arrivesLater.many': 'يصل بعد {count} يومًا',
'assistant.card.booking.reference': 'رقم الحجز محفوظ',
'assistant.card.booking.referenceHint': 'يبقى رقم الحجز مع الحجز، لا في المحادثة',
'assistant.card.booking.seat': 'المقعد {seat}',
'assistant.card.booking.platform': 'الرصيف {platform}',
'assistant.card.booking.checkInAt': 'تسجيل الوصول {time}',
'assistant.card.booking.checkOutAt': 'تسجيل المغادرة {time}',
'assistant.card.booking.cancelled': 'ملغى',
'assistant.card.booking.inTrip': 'في رحلة {trip}',
'assistant.card.booking.range': 'من {from} إلى {to}',
'assistant.change.verb.addStay': 'إضافة إقامة',
'assistant.change.verb.updateStay': 'تغيير الإقامة',
'assistant.change.verb.updateReservation': 'تغيير الحجز',
'assistant.tool.set_reservation_travelers': 'تغيير المسافرين',
'assistant.tool.link_hotel_accommodation': 'ربط الفندق بلياليه',
'assistant.tool.update_transport': 'تغيير وسيلة النقل',
'assistant.tool.delete_transport': 'حذف وسيلة النقل',
'assistant.tool.delete_accommodation': 'إلغاء الإقامة',
'assistant.tool.reorder_reservations': 'إعادة ترتيب الحجوزات',
'assistant.tool.import_airtrail_flights': 'استيراد الرحلات من AirTrail',
'assistant.step.bookings': 'قراءة الحجوزات',
'assistant.step.stays': 'قراءة أماكن إقامتك',
'assistant.step.upcoming': 'الاطلاع على الحجوزات القادمة',
'assistant.followUp.bookingsOpen': 'أي الحجوزات ما زالت معلقة؟',
'assistant.followUp.bookingsNights': 'هل لكل ليلة مكان إقامة؟',
'assistant.followUp.bookingsConfirm': 'تأكيد الحجوزات المعلقة',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Rezervasiyalar',
'assistant.card.booking.open': 'Rezervasiyanı aç',
'assistant.card.booking.openStay': 'Planda göstər',
'assistant.card.booking.more': 'Daha {count} rezervasiya',
'assistant.card.booking.more.one': 'Daha {count} rezervasiya',
'assistant.card.booking.nights': '{count} gecə',
'assistant.card.booking.nights.one': '{count} gecə',
'assistant.card.booking.stops': '{count} aralıq dayanacaq',
'assistant.card.booking.stops.one': '{count} aralıq dayanacaq',
'assistant.card.booking.direct': 'Birbaşa',
'assistant.card.booking.layoverAt': '{place} şəhərində {duration}',
'assistant.card.booking.arrivesLater': '{count} gün sonra çatır',
'assistant.card.booking.arrivesLater.one': '{count} gün sonra çatır',
'assistant.card.booking.reference': 'Rezervasiya kodu saxlanıldı',
'assistant.card.booking.referenceHint': 'Rezervasiya kodu söhbətdə deyil, rezervasiyada saxlanılır',
'assistant.card.booking.seat': 'Oturacaq {seat}',
'assistant.card.booking.platform': 'Platforma {platform}',
'assistant.card.booking.checkInAt': 'Giriş {time}',
'assistant.card.booking.checkOutAt': 'Çıxış {time}',
'assistant.card.booking.cancelled': 'Ləğv edilib',
'assistant.card.booking.inTrip': '{trip} səfərində',
'assistant.card.booking.range': '{from} ilə {to} arası',
'assistant.change.verb.addStay': 'Yerləşmə əlavə et',
'assistant.change.verb.updateStay': 'Qalmanı dəyiş',
'assistant.change.verb.updateReservation': 'Rezervasiyanı dəyiş',
'assistant.tool.set_reservation_travelers': 'Kimin səyahət etdiyini dəyiş',
'assistant.tool.link_hotel_accommodation': 'Oteli gecələrinə bağla',
'assistant.tool.update_transport': 'Nəqliyyatı dəyiş',
'assistant.tool.delete_transport': 'Nəqliyyatı sil',
'assistant.tool.delete_accommodation': 'Qalmanı ləğv et',
'assistant.tool.reorder_reservations': 'Rezervasiyaları yenidən sırala',
'assistant.tool.import_airtrail_flights': 'AirTrail-dan uçuşları idxal et',
'assistant.step.bookings': 'Rezervasiyalar oxunur',
'assistant.step.stays': 'Qalacağınız yerlər oxunur',
'assistant.step.upcoming': 'Yaxınlaşan rezervasiyalara baxılır',
'assistant.followUp.bookingsOpen': 'Hansı rezervasiyalar hələ gözləyir?',
'assistant.followUp.bookingsNights': 'Hər gecə üçün qalacaq yer varmı?',
'assistant.followUp.bookingsConfirm': 'Gözləyən rezervasiyaları təsdiqlə',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Reservas',
'assistant.card.booking.open': 'Abrir a reserva',
'assistant.card.booking.openStay': 'Mostrar no plano',
'assistant.card.booking.more': 'Mais {count} reservas',
'assistant.card.booking.more.one': 'Mais {count} reserva',
'assistant.card.booking.nights': '{count} noites',
'assistant.card.booking.nights.one': '{count} noite',
'assistant.card.booking.stops': '{count} escalas',
'assistant.card.booking.stops.one': '{count} escala',
'assistant.card.booking.direct': 'Direto',
'assistant.card.booking.layoverAt': '{duration} em {place}',
'assistant.card.booking.arrivesLater': 'Chega {count} dias depois',
'assistant.card.booking.arrivesLater.one': 'Chega {count} dia depois',
'assistant.card.booking.reference': 'Código salvo',
'assistant.card.booking.referenceHint': 'O código da reserva fica com a reserva, não no chat',
'assistant.card.booking.seat': 'Assento {seat}',
'assistant.card.booking.platform': 'Plataforma {platform}',
'assistant.card.booking.checkInAt': 'Entrada {time}',
'assistant.card.booking.checkOutAt': 'Saída {time}',
'assistant.card.booking.cancelled': 'Cancelada',
'assistant.card.booking.inTrip': 'Em {trip}',
'assistant.card.booking.range': 'de {from} a {to}',
'assistant.change.verb.addStay': 'Adicionar hospedagem',
'assistant.change.verb.updateStay': 'Alterar a estadia',
'assistant.change.verb.updateReservation': 'Alterar a reserva',
'assistant.tool.set_reservation_travelers': 'Alterar quem viaja',
'assistant.tool.link_hotel_accommodation': 'Vincular o hotel às suas noites',
'assistant.tool.update_transport': 'Alterar o transporte',
'assistant.tool.delete_transport': 'Excluir o transporte',
'assistant.tool.delete_accommodation': 'Cancelar a estadia',
'assistant.tool.reorder_reservations': 'Reordenar as reservas',
'assistant.tool.import_airtrail_flights': 'Importar voos do AirTrail',
'assistant.step.bookings': 'Lendo as reservas',
'assistant.step.stays': 'Lendo onde você se hospeda',
'assistant.step.upcoming': 'Vendo as próximas reservas',
'assistant.followUp.bookingsOpen': 'Quais reservas ainda estão pendentes?',
'assistant.followUp.bookingsNights': 'Toda noite tem hospedagem?',
'assistant.followUp.bookingsConfirm': 'Confirmar as reservas pendentes',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Reserves',
'assistant.card.booking.open': 'Obre la reserva',
'assistant.card.booking.openStay': 'Mostra al pla',
'assistant.card.booking.more': '{count} reserves més',
'assistant.card.booking.more.one': '{count} reserva més',
'assistant.card.booking.nights': '{count} nits',
'assistant.card.booking.nights.one': '{count} nit',
'assistant.card.booking.stops': '{count} escales',
'assistant.card.booking.stops.one': '{count} escala',
'assistant.card.booking.direct': 'Directe',
'assistant.card.booking.layoverAt': '{duration} a {place}',
'assistant.card.booking.arrivesLater': 'Arriba {count} dies després',
'assistant.card.booking.arrivesLater.one': 'Arriba {count} dia després',
'assistant.card.booking.reference': 'Referència desada',
'assistant.card.booking.referenceHint': 'La referència es queda amb la reserva, no al xat',
'assistant.card.booking.seat': 'Seient {seat}',
'assistant.card.booking.platform': 'Andana {platform}',
'assistant.card.booking.checkInAt': 'Entrada {time}',
'assistant.card.booking.checkOutAt': 'Sortida {time}',
'assistant.card.booking.cancelled': 'Cancel·lada',
'assistant.card.booking.inTrip': 'A {trip}',
'assistant.card.booking.range': 'del {from} al {to}',
'assistant.change.verb.addStay': 'Afegeix allotjament',
'assistant.change.verb.updateStay': 'Canvia l’estada',
'assistant.change.verb.updateReservation': 'Canvia la reserva',
'assistant.tool.set_reservation_travelers': 'Canvia qui viatja',
'assistant.tool.link_hotel_accommodation': 'Vincula l’hotel a les seves nits',
'assistant.tool.update_transport': 'Canvia el transport',
'assistant.tool.delete_transport': 'Elimina el transport',
'assistant.tool.delete_accommodation': 'Cancel·la l’estada',
'assistant.tool.reorder_reservations': 'Reordena les reserves',
'assistant.tool.import_airtrail_flights': 'Importa vols d’AirTrail',
'assistant.step.bookings': 'Llegint les reserves',
'assistant.step.stays': 'Llegint on t’allotges',
'assistant.step.upcoming': 'Mirant les properes reserves',
'assistant.followUp.bookingsOpen': 'Quines reserves encara estan pendents?',
'assistant.followUp.bookingsNights': 'Cada nit té allotjament?',
'assistant.followUp.bookingsConfirm': 'Confirma les reserves pendents',
};
export default assistantBooking;
+44 -1
View File
@@ -1,4 +1,47 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Rezervace',
'assistant.card.booking.open': 'Otevřít rezervaci',
'assistant.card.booking.openStay': 'Zobrazit v plánu',
'assistant.card.booking.more': 'Dalších {count} rezervací',
'assistant.card.booking.more.one': 'Ještě {count} rezervace',
'assistant.card.booking.more.few': 'Další {count} rezervace',
'assistant.card.booking.nights': '{count} nocí',
'assistant.card.booking.nights.one': '{count} noc',
'assistant.card.booking.nights.few': '{count} noci',
'assistant.card.booking.stops': '{count} přestupů',
'assistant.card.booking.stops.one': '{count} přestup',
'assistant.card.booking.stops.few': '{count} přestupy',
'assistant.card.booking.direct': 'Přímý spoj',
'assistant.card.booking.layoverAt': '{duration} v {place}',
'assistant.card.booking.arrivesLater': 'Přílet o {count} dní později',
'assistant.card.booking.arrivesLater.one': 'Přílet o {count} den později',
'assistant.card.booking.arrivesLater.few': 'Přílet o {count} dny později',
'assistant.card.booking.reference': 'Kód rezervace uložen',
'assistant.card.booking.referenceHint': 'Kód rezervace zůstává u rezervace, ne v chatu',
'assistant.card.booking.seat': 'Sedadlo {seat}',
'assistant.card.booking.platform': 'Nástupiště {platform}',
'assistant.card.booking.checkInAt': 'Příjezd {time}',
'assistant.card.booking.checkOutAt': 'Odjezd {time}',
'assistant.card.booking.cancelled': 'Zrušeno',
'assistant.card.booking.inTrip': 'V cestě {trip}',
'assistant.card.booking.range': '{from} až {to}',
'assistant.change.verb.addStay': 'Přidat ubytování',
'assistant.change.verb.updateStay': 'Změnit pobyt',
'assistant.change.verb.updateReservation': 'Změnit rezervaci',
'assistant.tool.set_reservation_travelers': 'Změnit, kdo cestuje',
'assistant.tool.link_hotel_accommodation': 'Propojit hotel s jeho nocemi',
'assistant.tool.update_transport': 'Změnit dopravu',
'assistant.tool.delete_transport': 'Smazat dopravu',
'assistant.tool.delete_accommodation': 'Zrušit pobyt',
'assistant.tool.reorder_reservations': 'Přeřadit rezervace',
'assistant.tool.import_airtrail_flights': 'Importovat lety z AirTrail',
'assistant.step.bookings': 'Čtu rezervace',
'assistant.step.stays': 'Čtu, kde bydlíš',
'assistant.step.upcoming': 'Dívám se na nadcházející rezervace',
'assistant.followUp.bookingsOpen': 'Které rezervace ještě čekají?',
'assistant.followUp.bookingsNights': 'Má každá noc ubytování?',
'assistant.followUp.bookingsConfirm': 'Potvrdit čekající rezervace',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Buchungen',
'assistant.card.booking.open': 'Buchung öffnen',
'assistant.card.booking.openStay': 'Im Plan zeigen',
'assistant.card.booking.more': '{count} weitere Buchungen',
'assistant.card.booking.more.one': '{count} weitere Buchung',
'assistant.card.booking.nights': '{count} Nächte',
'assistant.card.booking.nights.one': '{count} Nacht',
'assistant.card.booking.stops': '{count} Zwischenstopps',
'assistant.card.booking.stops.one': '{count} Zwischenstopp',
'assistant.card.booking.direct': 'Direktverbindung',
'assistant.card.booking.layoverAt': '{duration} Aufenthalt in {place}',
'assistant.card.booking.arrivesLater': 'Ankunft {count} Tage später',
'assistant.card.booking.arrivesLater.one': 'Ankunft {count} Tag später',
'assistant.card.booking.reference': 'Buchungsnummer gespeichert',
'assistant.card.booking.referenceHint': 'Die Buchungsnummer bleibt bei der Buchung, nicht im Chat',
'assistant.card.booking.seat': 'Sitz {seat}',
'assistant.card.booking.platform': 'Gleis {platform}',
'assistant.card.booking.checkInAt': 'Anreise {time}',
'assistant.card.booking.checkOutAt': 'Abreise {time}',
'assistant.card.booking.cancelled': 'Storniert',
'assistant.card.booking.inTrip': 'Reise: {trip}',
'assistant.card.booking.range': '{from} bis {to}',
'assistant.change.verb.addStay': 'Unterkunft hinzufügen',
'assistant.change.verb.updateStay': 'Aufenthalt ändern',
'assistant.change.verb.updateReservation': 'Buchung ändern',
'assistant.tool.set_reservation_travelers': 'Reisende ändern',
'assistant.tool.link_hotel_accommodation': 'Hotel mit seinen Nächten verknüpfen',
'assistant.tool.update_transport': 'Transport ändern',
'assistant.tool.delete_transport': 'Transport löschen',
'assistant.tool.delete_accommodation': 'Aufenthalt stornieren',
'assistant.tool.reorder_reservations': 'Buchungen neu ordnen',
'assistant.tool.import_airtrail_flights': 'Flüge aus AirTrail importieren',
'assistant.step.bookings': 'Liest die Buchungen',
'assistant.step.stays': 'Liest, wo du übernachtest',
'assistant.step.upcoming': 'Sieht kommende Buchungen durch',
'assistant.followUp.bookingsOpen': 'Welche Buchungen sind noch offen?',
'assistant.followUp.bookingsNights': 'Hat jede Nacht eine Unterkunft?',
'assistant.followUp.bookingsConfirm': 'Offene Buchungen bestätigen',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Bookings',
'assistant.card.booking.open': 'Open the booking',
'assistant.card.booking.openStay': 'Show in the plan',
'assistant.card.booking.more': '{count} more bookings',
'assistant.card.booking.more.one': '{count} more booking',
'assistant.card.booking.nights': '{count} nights',
'assistant.card.booking.nights.one': '{count} night',
'assistant.card.booking.stops': '{count} stops',
'assistant.card.booking.stops.one': '{count} stop',
'assistant.card.booking.direct': 'Direct',
'assistant.card.booking.layoverAt': '{duration} in {place}',
'assistant.card.booking.arrivesLater': 'Arrives {count} days later',
'assistant.card.booking.arrivesLater.one': 'Arrives {count} day later',
'assistant.card.booking.reference': 'Reference saved',
'assistant.card.booking.referenceHint': 'The booking reference is kept with the booking, not in the chat',
'assistant.card.booking.seat': 'Seat {seat}',
'assistant.card.booking.platform': 'Platform {platform}',
'assistant.card.booking.checkInAt': 'Check-in {time}',
'assistant.card.booking.checkOutAt': 'Check-out {time}',
'assistant.card.booking.cancelled': 'Cancelled',
'assistant.card.booking.inTrip': 'In {trip}',
'assistant.card.booking.range': '{from} to {to}',
'assistant.change.verb.addStay': 'Add accommodation',
'assistant.change.verb.updateStay': 'Change the stay',
'assistant.change.verb.updateReservation': 'Change the booking',
'assistant.tool.set_reservation_travelers': 'Change who travels',
'assistant.tool.link_hotel_accommodation': 'Link the hotel to its nights',
'assistant.tool.update_transport': 'Change the transport',
'assistant.tool.delete_transport': 'Delete the transport',
'assistant.tool.delete_accommodation': 'Cancel the stay',
'assistant.tool.reorder_reservations': 'Reorder the bookings',
'assistant.tool.import_airtrail_flights': 'Import flights from AirTrail',
'assistant.step.bookings': 'Reading the bookings',
'assistant.step.stays': 'Reading where you stay',
'assistant.step.upcoming': 'Looking at upcoming bookings',
'assistant.followUp.bookingsOpen': 'Which bookings are still pending?',
'assistant.followUp.bookingsNights': 'Is every night covered by a stay?',
'assistant.followUp.bookingsConfirm': 'Confirm the pending bookings',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Reservas',
'assistant.card.booking.open': 'Abrir la reserva',
'assistant.card.booking.openStay': 'Mostrar en el plan',
'assistant.card.booking.more': '{count} reservas más',
'assistant.card.booking.more.one': '{count} reserva más',
'assistant.card.booking.nights': '{count} noches',
'assistant.card.booking.nights.one': '{count} noche',
'assistant.card.booking.stops': '{count} escalas',
'assistant.card.booking.stops.one': '{count} escala',
'assistant.card.booking.direct': 'Directo',
'assistant.card.booking.layoverAt': '{duration} en {place}',
'assistant.card.booking.arrivesLater': 'Llega {count} días después',
'assistant.card.booking.arrivesLater.one': 'Llega {count} día después',
'assistant.card.booking.reference': 'Referencia guardada',
'assistant.card.booking.referenceHint': 'La referencia se guarda con la reserva, no en el chat',
'assistant.card.booking.seat': 'Asiento {seat}',
'assistant.card.booking.platform': 'Andén {platform}',
'assistant.card.booking.checkInAt': 'Entrada {time}',
'assistant.card.booking.checkOutAt': 'Salida {time}',
'assistant.card.booking.cancelled': 'Cancelada',
'assistant.card.booking.inTrip': 'En {trip}',
'assistant.card.booking.range': 'del {from} al {to}',
'assistant.change.verb.addStay': 'Añadir alojamiento',
'assistant.change.verb.updateStay': 'Cambiar la estancia',
'assistant.change.verb.updateReservation': 'Cambiar la reserva',
'assistant.tool.set_reservation_travelers': 'Cambiar quién viaja',
'assistant.tool.link_hotel_accommodation': 'Vincular el hotel a sus noches',
'assistant.tool.update_transport': 'Cambiar el transporte',
'assistant.tool.delete_transport': 'Eliminar el transporte',
'assistant.tool.delete_accommodation': 'Cancelar la estancia',
'assistant.tool.reorder_reservations': 'Reordenar las reservas',
'assistant.tool.import_airtrail_flights': 'Importar vuelos de AirTrail',
'assistant.step.bookings': 'Leyendo las reservas',
'assistant.step.stays': 'Leyendo dónde te alojas',
'assistant.step.upcoming': 'Mirando las próximas reservas',
'assistant.followUp.bookingsOpen': '¿Qué reservas siguen pendientes?',
'assistant.followUp.bookingsNights': '¿Tiene cada noche un alojamiento?',
'assistant.followUp.bookingsConfirm': 'Confirmar las reservas pendientes',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Broneeringud',
'assistant.card.booking.open': 'Ava broneering',
'assistant.card.booking.openStay': 'Näita plaanis',
'assistant.card.booking.more': 'Veel {count} broneeringut',
'assistant.card.booking.more.one': 'Veel {count} broneering',
'assistant.card.booking.nights': '{count} ööd',
'assistant.card.booking.nights.one': '{count} öö',
'assistant.card.booking.stops': '{count} vahepeatust',
'assistant.card.booking.stops.one': '{count} vahepeatus',
'assistant.card.booking.direct': 'Otse',
'assistant.card.booking.layoverAt': '{duration} kohas {place}',
'assistant.card.booking.arrivesLater': 'Saabub {count} päeva hiljem',
'assistant.card.booking.arrivesLater.one': 'Saabub {count} päev hiljem',
'assistant.card.booking.reference': 'Broneeringukood salvestatud',
'assistant.card.booking.referenceHint': 'Broneeringukood jääb broneeringu juurde, mitte vestlusesse',
'assistant.card.booking.seat': 'Istekoht {seat}',
'assistant.card.booking.platform': 'Perroon {platform}',
'assistant.card.booking.checkInAt': 'Sisseregistreerimine {time}',
'assistant.card.booking.checkOutAt': 'Väljaregistreerimine {time}',
'assistant.card.booking.cancelled': 'Tühistatud',
'assistant.card.booking.inTrip': 'Reisil {trip}',
'assistant.card.booking.range': '{from} kuni {to}',
'assistant.change.verb.addStay': 'Lisa majutus',
'assistant.change.verb.updateStay': 'Muuda peatumist',
'assistant.change.verb.updateReservation': 'Muuda broneeringut',
'assistant.tool.set_reservation_travelers': 'Muuda, kes reisivad',
'assistant.tool.link_hotel_accommodation': 'Seo hotell selle öödega',
'assistant.tool.update_transport': 'Muuda transporti',
'assistant.tool.delete_transport': 'Kustuta transport',
'assistant.tool.delete_accommodation': 'Tühista peatumine',
'assistant.tool.reorder_reservations': 'Järjesta broneeringud ümber',
'assistant.tool.import_airtrail_flights': 'Impordi lennud AirTrailist',
'assistant.step.bookings': 'Loen broneeringuid',
'assistant.step.stays': 'Loen, kus sa ööbid',
'assistant.step.upcoming': 'Vaatan eelseisvaid broneeringuid',
'assistant.followUp.bookingsOpen': 'Millised broneeringud on veel ootel?',
'assistant.followUp.bookingsNights': 'Kas igaks ööks on majutus?',
'assistant.followUp.bookingsConfirm': 'Kinnita ootel broneeringud',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Réservations',
'assistant.card.booking.open': 'Ouvrir la réservation',
'assistant.card.booking.openStay': 'Afficher dans le plan',
'assistant.card.booking.more': '{count} autres réservations',
'assistant.card.booking.more.one': '{count} autre réservation',
'assistant.card.booking.nights': '{count} nuits',
'assistant.card.booking.nights.one': '{count} nuit',
'assistant.card.booking.stops': '{count} escales',
'assistant.card.booking.stops.one': '{count} escale',
'assistant.card.booking.direct': 'Sans escale',
'assistant.card.booking.layoverAt': '{duration} à {place}',
'assistant.card.booking.arrivesLater': 'Arrivée {count} jours plus tard',
'assistant.card.booking.arrivesLater.one': 'Arrivée {count} jour plus tard',
'assistant.card.booking.reference': 'Référence enregistrée',
'assistant.card.booking.referenceHint': 'La référence reste avec la réservation, pas dans la discussion',
'assistant.card.booking.seat': 'Siège {seat}',
'assistant.card.booking.platform': 'Voie {platform}',
'assistant.card.booking.checkInAt': 'Arrivée {time}',
'assistant.card.booking.checkOutAt': 'Départ {time}',
'assistant.card.booking.cancelled': 'Annulée',
'assistant.card.booking.inTrip': 'Dans {trip}',
'assistant.card.booking.range': 'du {from} au {to}',
'assistant.change.verb.addStay': 'Ajouter un hébergement',
'assistant.change.verb.updateStay': 'Modifier le séjour',
'assistant.change.verb.updateReservation': 'Modifier la réservation',
'assistant.tool.set_reservation_travelers': 'Modifier les voyageurs',
'assistant.tool.link_hotel_accommodation': "Relier l'hôtel à ses nuits",
'assistant.tool.update_transport': 'Modifier le transport',
'assistant.tool.delete_transport': 'Supprimer le transport',
'assistant.tool.delete_accommodation': 'Annuler le séjour',
'assistant.tool.reorder_reservations': 'Réordonner les réservations',
'assistant.tool.import_airtrail_flights': 'Importer des vols depuis AirTrail',
'assistant.step.bookings': 'Lecture des réservations',
'assistant.step.stays': 'Lecture des hébergements',
'assistant.step.upcoming': 'Coup d’œil aux prochaines réservations',
'assistant.followUp.bookingsOpen': 'Quelles réservations sont encore en attente ?',
'assistant.followUp.bookingsNights': 'Chaque nuit a-t-elle un hébergement ?',
'assistant.followUp.bookingsConfirm': 'Confirmer les réservations en attente',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Κρατήσεις',
'assistant.card.booking.open': 'Άνοιγμα κράτησης',
'assistant.card.booking.openStay': 'Εμφάνιση στο πλάνο',
'assistant.card.booking.more': 'Ακόμη {count} κρατήσεις',
'assistant.card.booking.more.one': 'Ακόμη {count} κράτηση',
'assistant.card.booking.nights': '{count} νύχτες',
'assistant.card.booking.nights.one': '{count} νύχτα',
'assistant.card.booking.stops': '{count} στάσεις',
'assistant.card.booking.stops.one': '{count} στάση',
'assistant.card.booking.direct': 'Απευθείας',
'assistant.card.booking.layoverAt': '{duration} σε {place}',
'assistant.card.booking.arrivesLater': 'Άφιξη {count} ημέρες αργότερα',
'assistant.card.booking.arrivesLater.one': 'Άφιξη {count} ημέρα αργότερα',
'assistant.card.booking.reference': 'Ο κωδικός κράτησης αποθηκεύτηκε',
'assistant.card.booking.referenceHint': 'Ο κωδικός μένει στην κράτηση, όχι στη συνομιλία',
'assistant.card.booking.seat': 'Θέση {seat}',
'assistant.card.booking.platform': 'Αποβάθρα {platform}',
'assistant.card.booking.checkInAt': 'Άφιξη {time}',
'assistant.card.booking.checkOutAt': 'Αναχώρηση {time}',
'assistant.card.booking.cancelled': 'Ακυρώθηκε',
'assistant.card.booking.inTrip': 'Στο ταξίδι {trip}',
'assistant.card.booking.range': '{from} έως {to}',
'assistant.change.verb.addStay': 'Προσθήκη διαμονής',
'assistant.change.verb.updateStay': 'Αλλαγή διαμονής',
'assistant.change.verb.updateReservation': 'Αλλαγή κράτησης',
'assistant.tool.set_reservation_travelers': 'Αλλαγή ταξιδιωτών',
'assistant.tool.link_hotel_accommodation': 'Σύνδεση του ξενοδοχείου με τις νύχτες του',
'assistant.tool.update_transport': 'Αλλαγή μεταφοράς',
'assistant.tool.delete_transport': 'Διαγραφή μεταφοράς',
'assistant.tool.delete_accommodation': 'Ακύρωση διαμονής',
'assistant.tool.reorder_reservations': 'Αναδιάταξη κρατήσεων',
'assistant.tool.import_airtrail_flights': 'Εισαγωγή πτήσεων από το AirTrail',
'assistant.step.bookings': 'Διαβάζω τις κρατήσεις',
'assistant.step.stays': 'Διαβάζω τα καταλύματά σας',
'assistant.step.upcoming': 'Κοιτάζω τις επόμενες κρατήσεις',
'assistant.followUp.bookingsOpen': 'Ποιες κρατήσεις εκκρεμούν ακόμη;',
'assistant.followUp.bookingsNights': 'Έχει κάθε νύχτα κατάλυμα;',
'assistant.followUp.bookingsConfirm': 'Επιβεβαίωση των εκκρεμών κρατήσεων',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Foglalások',
'assistant.card.booking.open': 'Foglalás megnyitása',
'assistant.card.booking.openStay': 'Megjelenítés a tervben',
'assistant.card.booking.more': 'Még {count} foglalás',
'assistant.card.booking.more.one': 'Még {count} foglalás',
'assistant.card.booking.nights': '{count} éjszaka',
'assistant.card.booking.nights.one': '{count} éjszaka',
'assistant.card.booking.stops': '{count} átszállás',
'assistant.card.booking.stops.one': '{count} átszállás',
'assistant.card.booking.direct': 'Közvetlen',
'assistant.card.booking.layoverAt': '{duration} itt: {place}',
'assistant.card.booking.arrivesLater': 'Érkezés {count} nappal később',
'assistant.card.booking.arrivesLater.one': 'Érkezés {count} nappal később',
'assistant.card.booking.reference': 'Foglalási kód elmentve',
'assistant.card.booking.referenceHint': 'A foglalási kód a foglalásnál marad, nem a csevegésben',
'assistant.card.booking.seat': 'Ülés {seat}',
'assistant.card.booking.platform': 'Vágány {platform}',
'assistant.card.booking.checkInAt': 'Bejelentkezés {time}',
'assistant.card.booking.checkOutAt': 'Kijelentkezés {time}',
'assistant.card.booking.cancelled': 'Lemondva',
'assistant.card.booking.inTrip': 'Utazás: {trip}',
'assistant.card.booking.range': '{from} és {to} között',
'assistant.change.verb.addStay': 'Szállás hozzáadása',
'assistant.change.verb.updateStay': 'Tartózkodás módosítása',
'assistant.change.verb.updateReservation': 'Foglalás módosítása',
'assistant.tool.set_reservation_travelers': 'Utasok módosítása',
'assistant.tool.link_hotel_accommodation': 'Szálloda összekapcsolása az éjszakáival',
'assistant.tool.update_transport': 'Közlekedés módosítása',
'assistant.tool.delete_transport': 'Közlekedés törlése',
'assistant.tool.delete_accommodation': 'Tartózkodás lemondása',
'assistant.tool.reorder_reservations': 'Foglalások átrendezése',
'assistant.tool.import_airtrail_flights': 'Járatok importálása az AirTrailből',
'assistant.step.bookings': 'A foglalások olvasása',
'assistant.step.stays': 'A szállások olvasása',
'assistant.step.upcoming': 'A közelgő foglalások áttekintése',
'assistant.followUp.bookingsOpen': 'Mely foglalások várnak még?',
'assistant.followUp.bookingsNights': 'Minden éjszakára van szállás?',
'assistant.followUp.bookingsConfirm': 'Függő foglalások megerősítése',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Pemesanan',
'assistant.card.booking.open': 'Buka pemesanan',
'assistant.card.booking.openStay': 'Tampilkan di rencana',
'assistant.card.booking.more': '{count} pemesanan lagi',
'assistant.card.booking.nights': '{count} malam',
'assistant.card.booking.stops': '{count} transit',
'assistant.card.booking.direct': 'Langsung',
'assistant.card.booking.layoverAt': '{duration} di {place}',
'assistant.card.booking.arrivesLater': 'Tiba {count} hari kemudian',
'assistant.card.booking.reference': 'Kode pemesanan tersimpan',
'assistant.card.booking.referenceHint': 'Kode pemesanan disimpan bersama pemesanan, bukan di obrolan',
'assistant.card.booking.seat': 'Kursi {seat}',
'assistant.card.booking.platform': 'Peron {platform}',
'assistant.card.booking.checkInAt': 'Masuk {time}',
'assistant.card.booking.checkOutAt': 'Keluar {time}',
'assistant.card.booking.cancelled': 'Dibatalkan',
'assistant.card.booking.inTrip': 'Di {trip}',
'assistant.card.booking.range': '{from} sampai {to}',
'assistant.change.verb.addStay': 'Tambah penginapan',
'assistant.change.verb.updateStay': 'Ubah masa menginap',
'assistant.change.verb.updateReservation': 'Ubah pemesanan',
'assistant.tool.set_reservation_travelers': 'Ubah siapa yang bepergian',
'assistant.tool.link_hotel_accommodation': 'Tautkan hotel ke malam-malamnya',
'assistant.tool.update_transport': 'Ubah transportasi',
'assistant.tool.delete_transport': 'Hapus transportasi',
'assistant.tool.delete_accommodation': 'Batalkan masa menginap',
'assistant.tool.reorder_reservations': 'Urutkan ulang pemesanan',
'assistant.tool.import_airtrail_flights': 'Impor penerbangan dari AirTrail',
'assistant.step.bookings': 'Membaca pemesanan',
'assistant.step.stays': 'Membaca tempat menginap',
'assistant.step.upcoming': 'Melihat pemesanan mendatang',
'assistant.followUp.bookingsOpen': 'Pemesanan mana yang masih tertunda?',
'assistant.followUp.bookingsNights': 'Apakah setiap malam ada penginapan?',
'assistant.followUp.bookingsConfirm': 'Konfirmasi pemesanan yang tertunda',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Prenotazioni',
'assistant.card.booking.open': 'Apri la prenotazione',
'assistant.card.booking.openStay': 'Mostra nel piano',
'assistant.card.booking.more': 'Altre {count} prenotazioni',
'assistant.card.booking.more.one': 'Altra {count} prenotazione',
'assistant.card.booking.nights': '{count} notti',
'assistant.card.booking.nights.one': '{count} notte',
'assistant.card.booking.stops': '{count} scali',
'assistant.card.booking.stops.one': '{count} scalo',
'assistant.card.booking.direct': 'Diretto',
'assistant.card.booking.layoverAt': '{duration} a {place}',
'assistant.card.booking.arrivesLater': 'Arriva {count} giorni dopo',
'assistant.card.booking.arrivesLater.one': 'Arriva {count} giorno dopo',
'assistant.card.booking.reference': 'Codice salvato',
'assistant.card.booking.referenceHint': 'Il codice di prenotazione resta nella prenotazione, non nella chat',
'assistant.card.booking.seat': 'Posto {seat}',
'assistant.card.booking.platform': 'Binario {platform}',
'assistant.card.booking.checkInAt': 'Arrivo {time}',
'assistant.card.booking.checkOutAt': 'Partenza {time}',
'assistant.card.booking.cancelled': 'Annullata',
'assistant.card.booking.inTrip': 'Nel viaggio {trip}',
'assistant.card.booking.range': 'dal {from} al {to}',
'assistant.change.verb.addStay': 'Aggiungi alloggio',
'assistant.change.verb.updateStay': 'Modifica il soggiorno',
'assistant.change.verb.updateReservation': 'Modifica la prenotazione',
'assistant.tool.set_reservation_travelers': 'Cambia chi viaggia',
'assistant.tool.link_hotel_accommodation': "Collega l'hotel alle sue notti",
'assistant.tool.update_transport': 'Modifica il trasporto',
'assistant.tool.delete_transport': 'Elimina il trasporto',
'assistant.tool.delete_accommodation': 'Annulla il soggiorno',
'assistant.tool.reorder_reservations': 'Riordina le prenotazioni',
'assistant.tool.import_airtrail_flights': 'Importa voli da AirTrail',
'assistant.step.bookings': 'Leggo le prenotazioni',
'assistant.step.stays': 'Leggo dove alloggi',
'assistant.step.upcoming': 'Guardo le prossime prenotazioni',
'assistant.followUp.bookingsOpen': 'Quali prenotazioni sono ancora in sospeso?',
'assistant.followUp.bookingsNights': 'Ogni notte ha un alloggio?',
'assistant.followUp.bookingsConfirm': 'Conferma le prenotazioni in sospeso',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': '予約',
'assistant.card.booking.open': '予約を開く',
'assistant.card.booking.openStay': 'プランで表示',
'assistant.card.booking.more': 'ほか {count} 件の予約',
'assistant.card.booking.nights': '{count} 泊',
'assistant.card.booking.stops': '乗り継ぎ {count} 回',
'assistant.card.booking.direct': '直行便',
'assistant.card.booking.layoverAt': '{place} で {duration}',
'assistant.card.booking.arrivesLater': '{count} 日後に到着',
'assistant.card.booking.reference': '予約番号を保存済み',
'assistant.card.booking.referenceHint': '予約番号はチャットではなく予約に保存されます',
'assistant.card.booking.seat': '座席 {seat}',
'assistant.card.booking.platform': '{platform} 番線',
'assistant.card.booking.checkInAt': 'チェックイン {time}',
'assistant.card.booking.checkOutAt': 'チェックアウト {time}',
'assistant.card.booking.cancelled': 'キャンセル済み',
'assistant.card.booking.inTrip': '{trip} の予約',
'assistant.card.booking.range': '{from} から {to}',
'assistant.change.verb.addStay': '宿泊を追加',
'assistant.change.verb.updateStay': '宿泊を変更',
'assistant.change.verb.updateReservation': '予約を変更',
'assistant.tool.set_reservation_travelers': '旅行者を変更',
'assistant.tool.link_hotel_accommodation': 'ホテルを宿泊日に紐付け',
'assistant.tool.update_transport': '移動手段を変更',
'assistant.tool.delete_transport': '移動手段を削除',
'assistant.tool.delete_accommodation': '宿泊をキャンセル',
'assistant.tool.reorder_reservations': '予約を並べ替え',
'assistant.tool.import_airtrail_flights': 'AirTrail からフライトを取り込む',
'assistant.step.bookings': '予約一覧を読み込み中',
'assistant.step.stays': '宿泊先を読み込み中',
'assistant.step.upcoming': '今後の予約を確認中',
'assistant.followUp.bookingsOpen': 'まだ保留中の予約は?',
'assistant.followUp.bookingsNights': '毎晩の宿泊先はありますか?',
'assistant.followUp.bookingsConfirm': '保留中の予約を確定する',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': '예약',
'assistant.card.booking.open': '예약 열기',
'assistant.card.booking.openStay': '일정에서 보기',
'assistant.card.booking.more': '예약 {count}개 더',
'assistant.card.booking.nights': '{count}박',
'assistant.card.booking.stops': '경유 {count}회',
'assistant.card.booking.direct': '직항',
'assistant.card.booking.layoverAt': '{place}에서 {duration}',
'assistant.card.booking.arrivesLater': '{count}일 후 도착',
'assistant.card.booking.reference': '예약 번호 저장됨',
'assistant.card.booking.referenceHint': '예약 번호는 채팅이 아니라 예약에 보관됩니다',
'assistant.card.booking.seat': '좌석 {seat}',
'assistant.card.booking.platform': '{platform}번 승강장',
'assistant.card.booking.checkInAt': '체크인 {time}',
'assistant.card.booking.checkOutAt': '체크아웃 {time}',
'assistant.card.booking.cancelled': '취소됨',
'assistant.card.booking.inTrip': '{trip} 여행',
'assistant.card.booking.range': '{from}부터 {to}까지',
'assistant.change.verb.addStay': '숙소 추가',
'assistant.change.verb.updateStay': '숙박 변경',
'assistant.change.verb.updateReservation': '예약 변경',
'assistant.tool.set_reservation_travelers': '여행자 변경',
'assistant.tool.link_hotel_accommodation': '호텔을 숙박일에 연결',
'assistant.tool.update_transport': '교통편 변경',
'assistant.tool.delete_transport': '교통편 삭제',
'assistant.tool.delete_accommodation': '숙박 취소',
'assistant.tool.reorder_reservations': '예약 순서 변경',
'assistant.tool.import_airtrail_flights': 'AirTrail에서 항공편 가져오기',
'assistant.step.bookings': '예약 목록을 읽는 중',
'assistant.step.stays': '숙소를 읽는 중',
'assistant.step.upcoming': '다가오는 예약을 확인하는 중',
'assistant.followUp.bookingsOpen': '아직 대기 중인 예약은?',
'assistant.followUp.bookingsNights': '모든 밤에 숙소가 있나요?',
'assistant.followUp.bookingsConfirm': '대기 중인 예약 확정',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Boekingen',
'assistant.card.booking.open': 'Boeking openen',
'assistant.card.booking.openStay': 'Tonen in het plan',
'assistant.card.booking.more': 'Nog {count} boekingen',
'assistant.card.booking.more.one': 'Nog {count} boeking',
'assistant.card.booking.nights': '{count} nachten',
'assistant.card.booking.nights.one': '{count} nacht',
'assistant.card.booking.stops': '{count} tussenstops',
'assistant.card.booking.stops.one': '{count} tussenstop',
'assistant.card.booking.direct': 'Rechtstreeks',
'assistant.card.booking.layoverAt': '{duration} overstap in {place}',
'assistant.card.booking.arrivesLater': 'Aankomst {count} dagen later',
'assistant.card.booking.arrivesLater.one': 'Aankomst {count} dag later',
'assistant.card.booking.reference': 'Boekingscode bewaard',
'assistant.card.booking.referenceHint': 'De boekingscode blijft bij de boeking, niet in de chat',
'assistant.card.booking.seat': 'Stoel {seat}',
'assistant.card.booking.platform': 'Spoor {platform}',
'assistant.card.booking.checkInAt': 'Inchecken {time}',
'assistant.card.booking.checkOutAt': 'Uitchecken {time}',
'assistant.card.booking.cancelled': 'Geannuleerd',
'assistant.card.booking.inTrip': 'Bij {trip}',
'assistant.card.booking.range': '{from} tot {to}',
'assistant.change.verb.addStay': 'Accommodatie toevoegen',
'assistant.change.verb.updateStay': 'Verblijf wijzigen',
'assistant.change.verb.updateReservation': 'Boeking wijzigen',
'assistant.tool.set_reservation_travelers': 'Wijzigen wie meereist',
'assistant.tool.link_hotel_accommodation': 'Hotel aan zijn nachten koppelen',
'assistant.tool.update_transport': 'Vervoer wijzigen',
'assistant.tool.delete_transport': 'Vervoer verwijderen',
'assistant.tool.delete_accommodation': 'Verblijf annuleren',
'assistant.tool.reorder_reservations': 'Boekingen herschikken',
'assistant.tool.import_airtrail_flights': 'Vluchten uit AirTrail importeren',
'assistant.step.bookings': 'Boekingen lezen',
'assistant.step.stays': 'Lezen waar je verblijft',
'assistant.step.upcoming': 'Komende boekingen bekijken',
'assistant.followUp.bookingsOpen': 'Welke boekingen staan nog open?',
'assistant.followUp.bookingsNights': 'Heeft elke nacht een verblijf?',
'assistant.followUp.bookingsConfirm': 'Openstaande boekingen bevestigen',
};
export default assistantBooking;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Rezerwacje',
'assistant.card.booking.open': 'Otwórz rezerwację',
'assistant.card.booking.openStay': 'Pokaż w planie',
'assistant.card.booking.more': 'Jeszcze {count} rezerwacji',
'assistant.card.booking.more.one': 'Jeszcze {count} rezerwacja',
'assistant.card.booking.more.few': 'Jeszcze {count} rezerwacje',
'assistant.card.booking.more.many': 'Jeszcze {count} rezerwacji',
'assistant.card.booking.nights': '{count} nocy',
'assistant.card.booking.nights.one': '{count} noc',
'assistant.card.booking.nights.few': '{count} noce',
'assistant.card.booking.nights.many': '{count} nocy',
'assistant.card.booking.stops': '{count} przesiadki',
'assistant.card.booking.stops.one': '{count} przesiadka',
'assistant.card.booking.stops.few': '{count} przesiadki',
'assistant.card.booking.stops.many': '{count} przesiadek',
'assistant.card.booking.direct': 'Bezpośredni',
'assistant.card.booking.layoverAt': '{duration} w {place}',
'assistant.card.booking.arrivesLater': 'Przylot {count} dnia później',
'assistant.card.booking.arrivesLater.one': 'Przylot {count} dzień później',
'assistant.card.booking.arrivesLater.few': 'Przylot {count} dni później',
'assistant.card.booking.arrivesLater.many': 'Przylot {count} dni później',
'assistant.card.booking.reference': 'Kod rezerwacji zapisany',
'assistant.card.booking.referenceHint': 'Kod rezerwacji zostaje przy rezerwacji, nie w czacie',
'assistant.card.booking.seat': 'Miejsce {seat}',
'assistant.card.booking.platform': 'Peron {platform}',
'assistant.card.booking.checkInAt': 'Zameldowanie {time}',
'assistant.card.booking.checkOutAt': 'Wymeldowanie {time}',
'assistant.card.booking.cancelled': 'Anulowana',
'assistant.card.booking.inTrip': 'W podróży {trip}',
'assistant.card.booking.range': 'od {from} do {to}',
'assistant.change.verb.addStay': 'Dodaj nocleg',
'assistant.change.verb.updateStay': 'Zmień pobyt',
'assistant.change.verb.updateReservation': 'Zmień rezerwację',
'assistant.tool.set_reservation_travelers': 'Zmień, kto podróżuje',
'assistant.tool.link_hotel_accommodation': 'Powiąż hotel z jego nocami',
'assistant.tool.update_transport': 'Zmień transport',
'assistant.tool.delete_transport': 'Usuń transport',
'assistant.tool.delete_accommodation': 'Anuluj pobyt',
'assistant.tool.reorder_reservations': 'Zmień kolejność rezerwacji',
'assistant.tool.import_airtrail_flights': 'Importuj loty z AirTrail',
'assistant.step.bookings': 'Czytam rezerwacje',
'assistant.step.stays': 'Czytam, gdzie nocujesz',
'assistant.step.upcoming': 'Sprawdzam nadchodzące rezerwacje',
'assistant.followUp.bookingsOpen': 'Które rezerwacje wciąż czekają?',
'assistant.followUp.bookingsNights': 'Czy każda noc ma nocleg?',
'assistant.followUp.bookingsConfirm': 'Potwierdź oczekujące rezerwacje',
};
export default assistantBooking;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Бронирования',
'assistant.card.booking.open': 'Открыть бронирование',
'assistant.card.booking.openStay': 'Показать в плане',
'assistant.card.booking.more': 'Ещё {count} бронирования',
'assistant.card.booking.more.one': 'Ещё {count} бронирование',
'assistant.card.booking.more.few': 'Ещё {count} бронирования',
'assistant.card.booking.more.many': 'Ещё {count} бронирований',
'assistant.card.booking.nights': '{count} ночи',
'assistant.card.booking.nights.one': '{count} ночь',
'assistant.card.booking.nights.few': '{count} ночи',
'assistant.card.booking.nights.many': '{count} ночей',
'assistant.card.booking.stops': '{count} пересадки',
'assistant.card.booking.stops.one': '{count} пересадка',
'assistant.card.booking.stops.few': '{count} пересадки',
'assistant.card.booking.stops.many': '{count} пересадок',
'assistant.card.booking.direct': 'Без пересадок',
'assistant.card.booking.layoverAt': '{duration} в {place}',
'assistant.card.booking.arrivesLater': 'Прибытие через {count} дня',
'assistant.card.booking.arrivesLater.one': 'Прибытие через {count} день',
'assistant.card.booking.arrivesLater.few': 'Прибытие через {count} дня',
'assistant.card.booking.arrivesLater.many': 'Прибытие через {count} дней',
'assistant.card.booking.reference': 'Номер брони сохранён',
'assistant.card.booking.referenceHint': 'Номер брони хранится в бронировании, а не в чате',
'assistant.card.booking.seat': 'Место {seat}',
'assistant.card.booking.platform': 'Платформа {platform}',
'assistant.card.booking.checkInAt': 'Заезд {time}',
'assistant.card.booking.checkOutAt': 'Выезд {time}',
'assistant.card.booking.cancelled': 'Отменено',
'assistant.card.booking.inTrip': 'В поездке {trip}',
'assistant.card.booking.range': 'с {from} по {to}',
'assistant.change.verb.addStay': 'Добавить жильё',
'assistant.change.verb.updateStay': 'Изменить проживание',
'assistant.change.verb.updateReservation': 'Изменить бронирование',
'assistant.tool.set_reservation_travelers': 'Изменить, кто едет',
'assistant.tool.link_hotel_accommodation': 'Привязать отель к его ночам',
'assistant.tool.update_transport': 'Изменить транспорт',
'assistant.tool.delete_transport': 'Удалить транспорт',
'assistant.tool.delete_accommodation': 'Отменить проживание',
'assistant.tool.reorder_reservations': 'Изменить порядок бронирований',
'assistant.tool.import_airtrail_flights': 'Импортировать рейсы из AirTrail',
'assistant.step.bookings': 'Читаю бронирования',
'assistant.step.stays': 'Читаю, где вы живёте',
'assistant.step.upcoming': 'Смотрю ближайшие бронирования',
'assistant.followUp.bookingsOpen': 'Какие бронирования ещё ждут подтверждения?',
'assistant.followUp.bookingsNights': 'На каждую ли ночь есть жильё?',
'assistant.followUp.bookingsConfirm': 'Подтвердить ожидающие бронирования',
};
export default assistantBooking;
+44 -1
View File
@@ -1,4 +1,47 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Rezervácie',
'assistant.card.booking.open': 'Otvoriť rezerváciu',
'assistant.card.booking.openStay': 'Zobraziť v pláne',
'assistant.card.booking.more': 'Ďalších {count} rezervácií',
'assistant.card.booking.more.one': 'Ešte {count} rezervácia',
'assistant.card.booking.more.few': 'Ďalšie {count} rezervácie',
'assistant.card.booking.nights': '{count} nocí',
'assistant.card.booking.nights.one': '{count} noc',
'assistant.card.booking.nights.few': '{count} noci',
'assistant.card.booking.stops': '{count} prestupov',
'assistant.card.booking.stops.one': '{count} prestup',
'assistant.card.booking.stops.few': '{count} prestupy',
'assistant.card.booking.direct': 'Priamy spoj',
'assistant.card.booking.layoverAt': '{duration} v {place}',
'assistant.card.booking.arrivesLater': 'Prílet o {count} dní neskôr',
'assistant.card.booking.arrivesLater.one': 'Prílet o {count} deň neskôr',
'assistant.card.booking.arrivesLater.few': 'Prílet o {count} dni neskôr',
'assistant.card.booking.reference': 'Kód rezervácie uložený',
'assistant.card.booking.referenceHint': 'Kód rezervácie zostáva pri rezervácii, nie v chate',
'assistant.card.booking.seat': 'Sedadlo {seat}',
'assistant.card.booking.platform': 'Nástupište {platform}',
'assistant.card.booking.checkInAt': 'Príchod {time}',
'assistant.card.booking.checkOutAt': 'Odchod {time}',
'assistant.card.booking.cancelled': 'Zrušené',
'assistant.card.booking.inTrip': 'V ceste {trip}',
'assistant.card.booking.range': '{from} až {to}',
'assistant.change.verb.addStay': 'Pridať ubytovanie',
'assistant.change.verb.updateStay': 'Zmeniť pobyt',
'assistant.change.verb.updateReservation': 'Zmeniť rezerváciu',
'assistant.tool.set_reservation_travelers': 'Zmeniť, kto cestuje',
'assistant.tool.link_hotel_accommodation': 'Prepojiť hotel s jeho nocami',
'assistant.tool.update_transport': 'Zmeniť dopravu',
'assistant.tool.delete_transport': 'Zmazať dopravu',
'assistant.tool.delete_accommodation': 'Zrušiť pobyt',
'assistant.tool.reorder_reservations': 'Preradiť rezervácie',
'assistant.tool.import_airtrail_flights': 'Importovať lety z AirTrail',
'assistant.step.bookings': 'Čítam rezervácie',
'assistant.step.stays': 'Čítam, kde bývaš',
'assistant.step.upcoming': 'Pozerám nadchádzajúce rezervácie',
'assistant.followUp.bookingsOpen': 'Ktoré rezervácie ešte čakajú?',
'assistant.followUp.bookingsNights': 'Má každá noc ubytovanie?',
'assistant.followUp.bookingsConfirm': 'Potvrdiť čakajúce rezervácie',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Bokningar',
'assistant.card.booking.open': 'Öppna bokningen',
'assistant.card.booking.openStay': 'Visa i planen',
'assistant.card.booking.more': '{count} bokningar till',
'assistant.card.booking.more.one': '{count} bokning till',
'assistant.card.booking.nights': '{count} nätter',
'assistant.card.booking.nights.one': '{count} natt',
'assistant.card.booking.stops': '{count} mellanlandningar',
'assistant.card.booking.stops.one': '{count} mellanlandning',
'assistant.card.booking.direct': 'Direktresa',
'assistant.card.booking.layoverAt': '{duration} i {place}',
'assistant.card.booking.arrivesLater': 'Ankommer {count} dagar senare',
'assistant.card.booking.arrivesLater.one': 'Ankommer {count} dag senare',
'assistant.card.booking.reference': 'Bokningsnummer sparat',
'assistant.card.booking.referenceHint': 'Bokningsnumret stannar i bokningen, inte i chatten',
'assistant.card.booking.seat': 'Plats {seat}',
'assistant.card.booking.platform': 'Spår {platform}',
'assistant.card.booking.checkInAt': 'Incheckning {time}',
'assistant.card.booking.checkOutAt': 'Utcheckning {time}',
'assistant.card.booking.cancelled': 'Avbokad',
'assistant.card.booking.inTrip': 'I {trip}',
'assistant.card.booking.range': '{from} till {to}',
'assistant.change.verb.addStay': 'Lägg till boende',
'assistant.change.verb.updateStay': 'Ändra vistelsen',
'assistant.change.verb.updateReservation': 'Ändra bokningen',
'assistant.tool.set_reservation_travelers': 'Ändra vilka som reser',
'assistant.tool.link_hotel_accommodation': 'Koppla hotellet till dess nätter',
'assistant.tool.update_transport': 'Ändra transporten',
'assistant.tool.delete_transport': 'Ta bort transporten',
'assistant.tool.delete_accommodation': 'Avboka vistelsen',
'assistant.tool.reorder_reservations': 'Ordna om bokningarna',
'assistant.tool.import_airtrail_flights': 'Importera flyg från AirTrail',
'assistant.step.bookings': 'Läser bokningarna',
'assistant.step.stays': 'Läser var du bor',
'assistant.step.upcoming': 'Tittar på kommande bokningar',
'assistant.followUp.bookingsOpen': 'Vilka bokningar väntar fortfarande?',
'assistant.followUp.bookingsNights': 'Har varje natt ett boende?',
'assistant.followUp.bookingsConfirm': 'Bekräfta de väntande bokningarna',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'การจอง',
'assistant.card.booking.open': 'เปิดการจอง',
'assistant.card.booking.openStay': 'แสดงในแผน',
'assistant.card.booking.more': 'การจองอีก {count} รายการ',
'assistant.card.booking.nights': '{count} คืน',
'assistant.card.booking.stops': 'ต่อเครื่อง {count} ครั้ง',
'assistant.card.booking.direct': 'บินตรง',
'assistant.card.booking.layoverAt': '{duration} ที่ {place}',
'assistant.card.booking.arrivesLater': 'ถึง {count} วันถัดไป',
'assistant.card.booking.reference': 'บันทึกรหัสการจองแล้ว',
'assistant.card.booking.referenceHint': 'รหัสการจองเก็บไว้กับการจอง ไม่ใช่ในแชท',
'assistant.card.booking.seat': 'ที่นั่ง {seat}',
'assistant.card.booking.platform': 'ชานชาลา {platform}',
'assistant.card.booking.checkInAt': 'เช็กอิน {time}',
'assistant.card.booking.checkOutAt': 'เช็กเอาต์ {time}',
'assistant.card.booking.cancelled': 'ยกเลิกแล้ว',
'assistant.card.booking.inTrip': 'ในทริป {trip}',
'assistant.card.booking.range': '{from} ถึง {to}',
'assistant.change.verb.addStay': 'เพิ่มที่พัก',
'assistant.change.verb.updateStay': 'เปลี่ยนการเข้าพัก',
'assistant.change.verb.updateReservation': 'เปลี่ยนการจอง',
'assistant.tool.set_reservation_travelers': 'เปลี่ยนผู้เดินทาง',
'assistant.tool.link_hotel_accommodation': 'เชื่อมโรงแรมกับคืนที่พัก',
'assistant.tool.update_transport': 'เปลี่ยนการเดินทาง',
'assistant.tool.delete_transport': 'ลบการเดินทาง',
'assistant.tool.delete_accommodation': 'ยกเลิกการเข้าพัก',
'assistant.tool.reorder_reservations': 'จัดลำดับการจองใหม่',
'assistant.tool.import_airtrail_flights': 'นำเข้าเที่ยวบินจาก AirTrail',
'assistant.step.bookings': 'กำลังอ่านการจอง',
'assistant.step.stays': 'กำลังอ่านที่พัก',
'assistant.step.upcoming': 'กำลังดูการจองที่จะมาถึง',
'assistant.followUp.bookingsOpen': 'การจองไหนยังรอยืนยันอยู่?',
'assistant.followUp.bookingsNights': 'ทุกคืนมีที่พักหรือยัง?',
'assistant.followUp.bookingsConfirm': 'ยืนยันการจองที่รออยู่',
};
export default assistantBooking;
+40 -1
View File
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Rezervasyonlar',
'assistant.card.booking.open': 'Rezervasyonu aç',
'assistant.card.booking.openStay': 'Planda göster',
'assistant.card.booking.more': '{count} rezervasyon daha',
'assistant.card.booking.more.one': '{count} rezervasyon daha',
'assistant.card.booking.nights': '{count} gece',
'assistant.card.booking.nights.one': '{count} gece',
'assistant.card.booking.stops': '{count} aktarma',
'assistant.card.booking.stops.one': '{count} aktarma',
'assistant.card.booking.direct': 'Aktarmasız',
'assistant.card.booking.layoverAt': '{place} içinde {duration}',
'assistant.card.booking.arrivesLater': '{count} gün sonra varış',
'assistant.card.booking.arrivesLater.one': '{count} gün sonra varış',
'assistant.card.booking.reference': 'Rezervasyon kodu kaydedildi',
'assistant.card.booking.referenceHint': 'Rezervasyon kodu sohbette değil, rezervasyonda saklanır',
'assistant.card.booking.seat': 'Koltuk {seat}',
'assistant.card.booking.platform': 'Peron {platform}',
'assistant.card.booking.checkInAt': 'Giriş {time}',
'assistant.card.booking.checkOutAt': 'Çıkış {time}',
'assistant.card.booking.cancelled': 'İptal edildi',
'assistant.card.booking.inTrip': '{trip} içinde',
'assistant.card.booking.range': '{from} ile {to} arası',
'assistant.change.verb.addStay': 'Konaklama ekle',
'assistant.change.verb.updateStay': 'Konaklamayı değiştir',
'assistant.change.verb.updateReservation': 'Rezervasyonu değiştir',
'assistant.tool.set_reservation_travelers': 'Kimlerin seyahat ettiğini değiştir',
'assistant.tool.link_hotel_accommodation': 'Oteli gecelerine bağla',
'assistant.tool.update_transport': 'Ulaşımı değiştir',
'assistant.tool.delete_transport': 'Ulaşımı sil',
'assistant.tool.delete_accommodation': 'Konaklamayı iptal et',
'assistant.tool.reorder_reservations': 'Rezervasyonları yeniden sırala',
'assistant.tool.import_airtrail_flights': "AirTrail'den uçuşları içe aktar",
'assistant.step.bookings': 'Rezervasyonlar okunuyor',
'assistant.step.stays': 'Konaklamalar okunuyor',
'assistant.step.upcoming': 'Yaklaşan rezervasyonlara bakılıyor',
'assistant.followUp.bookingsOpen': 'Hangi rezervasyonlar hâlâ bekliyor?',
'assistant.followUp.bookingsNights': 'Her gece için konaklama var mı?',
'assistant.followUp.bookingsConfirm': 'Bekleyen rezervasyonları onayla',
};
export default assistantBooking;
+48 -1
View File
@@ -1,4 +1,51 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Бронювання',
'assistant.card.booking.open': 'Відкрити бронювання',
'assistant.card.booking.openStay': 'Показати в плані',
'assistant.card.booking.more': 'Ще {count} бронювання',
'assistant.card.booking.more.one': 'Ще {count} бронювання',
'assistant.card.booking.more.few': 'Ще {count} бронювання',
'assistant.card.booking.more.many': 'Ще {count} бронювань',
'assistant.card.booking.nights': '{count} ночі',
'assistant.card.booking.nights.one': '{count} ніч',
'assistant.card.booking.nights.few': '{count} ночі',
'assistant.card.booking.nights.many': '{count} ночей',
'assistant.card.booking.stops': '{count} пересадки',
'assistant.card.booking.stops.one': '{count} пересадка',
'assistant.card.booking.stops.few': '{count} пересадки',
'assistant.card.booking.stops.many': '{count} пересадок',
'assistant.card.booking.direct': 'Без пересадок',
'assistant.card.booking.layoverAt': '{duration} у {place}',
'assistant.card.booking.arrivesLater': 'Прибуття через {count} дня',
'assistant.card.booking.arrivesLater.one': 'Прибуття через {count} день',
'assistant.card.booking.arrivesLater.few': 'Прибуття через {count} дні',
'assistant.card.booking.arrivesLater.many': 'Прибуття через {count} днів',
'assistant.card.booking.reference': 'Номер бронювання збережено',
'assistant.card.booking.referenceHint': 'Номер бронювання зберігається в бронюванні, а не в чаті',
'assistant.card.booking.seat': 'Місце {seat}',
'assistant.card.booking.platform': 'Платформа {platform}',
'assistant.card.booking.checkInAt': 'Заїзд {time}',
'assistant.card.booking.checkOutAt': 'Виїзд {time}',
'assistant.card.booking.cancelled': 'Скасовано',
'assistant.card.booking.inTrip': 'У поїздці {trip}',
'assistant.card.booking.range': 'з {from} по {to}',
'assistant.change.verb.addStay': 'Додати житло',
'assistant.change.verb.updateStay': 'Змінити проживання',
'assistant.change.verb.updateReservation': 'Змінити бронювання',
'assistant.tool.set_reservation_travelers': 'Змінити, хто їде',
'assistant.tool.link_hotel_accommodation': "Прив'язати готель до його ночей",
'assistant.tool.update_transport': 'Змінити транспорт',
'assistant.tool.delete_transport': 'Видалити транспорт',
'assistant.tool.delete_accommodation': 'Скасувати проживання',
'assistant.tool.reorder_reservations': 'Змінити порядок бронювань',
'assistant.tool.import_airtrail_flights': 'Імпортувати рейси з AirTrail',
'assistant.step.bookings': 'Читаю бронювання',
'assistant.step.stays': 'Читаю, де ви живете',
'assistant.step.upcoming': 'Дивлюся найближчі бронювання',
'assistant.followUp.bookingsOpen': 'Які бронювання ще чекають?',
'assistant.followUp.bookingsNights': 'Чи є житло на кожну ніч?',
'assistant.followUp.bookingsConfirm': 'Підтвердити бронювання, що очікують',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': 'Đặt chỗ',
'assistant.card.booking.open': 'Mở đặt chỗ',
'assistant.card.booking.openStay': 'Hiển thị trong kế hoạch',
'assistant.card.booking.more': 'Còn {count} đặt chỗ',
'assistant.card.booking.nights': '{count} đêm',
'assistant.card.booking.stops': '{count} điểm dừng',
'assistant.card.booking.direct': 'Bay thẳng',
'assistant.card.booking.layoverAt': '{duration} tại {place}',
'assistant.card.booking.arrivesLater': 'Đến nơi sau {count} ngày',
'assistant.card.booking.reference': 'Đã lưu mã đặt chỗ',
'assistant.card.booking.referenceHint': 'Mã đặt chỗ được giữ trong đặt chỗ, không trong cuộc trò chuyện',
'assistant.card.booking.seat': 'Ghế {seat}',
'assistant.card.booking.platform': 'Sân ga {platform}',
'assistant.card.booking.checkInAt': 'Nhận phòng {time}',
'assistant.card.booking.checkOutAt': 'Trả phòng {time}',
'assistant.card.booking.cancelled': 'Đã hủy',
'assistant.card.booking.inTrip': 'Trong {trip}',
'assistant.card.booking.range': 'từ {from} đến {to}',
'assistant.change.verb.addStay': 'Thêm chỗ ở',
'assistant.change.verb.updateStay': 'Thay đổi kỳ lưu trú',
'assistant.change.verb.updateReservation': 'Thay đổi đặt chỗ',
'assistant.tool.set_reservation_travelers': 'Thay đổi người đi',
'assistant.tool.link_hotel_accommodation': 'Liên kết khách sạn với các đêm',
'assistant.tool.update_transport': 'Thay đổi phương tiện',
'assistant.tool.delete_transport': 'Xóa phương tiện',
'assistant.tool.delete_accommodation': 'Hủy kỳ lưu trú',
'assistant.tool.reorder_reservations': 'Sắp xếp lại đặt chỗ',
'assistant.tool.import_airtrail_flights': 'Nhập chuyến bay từ AirTrail',
'assistant.step.bookings': 'Đang đọc các đặt chỗ',
'assistant.step.stays': 'Đang đọc nơi bạn ở',
'assistant.step.upcoming': 'Đang xem các đặt chỗ sắp tới',
'assistant.followUp.bookingsOpen': 'Đặt chỗ nào vẫn đang chờ?',
'assistant.followUp.bookingsNights': 'Đêm nào cũng có chỗ ở chưa?',
'assistant.followUp.bookingsConfirm': 'Xác nhận các đặt chỗ đang chờ',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': '預訂',
'assistant.card.booking.open': '開啟預訂',
'assistant.card.booking.openStay': '在計畫中顯示',
'assistant.card.booking.more': '還有 {count} 項預訂',
'assistant.card.booking.nights': '{count} 晚',
'assistant.card.booking.stops': '轉機 {count} 次',
'assistant.card.booking.direct': '直飛',
'assistant.card.booking.layoverAt': '在{place}停留 {duration}',
'assistant.card.booking.arrivesLater': '{count} 天後抵達',
'assistant.card.booking.reference': '已儲存預訂代號',
'assistant.card.booking.referenceHint': '預訂代號保存在預訂中,而不是聊天裡',
'assistant.card.booking.seat': '座位 {seat}',
'assistant.card.booking.platform': '{platform} 月台',
'assistant.card.booking.checkInAt': '入住 {time}',
'assistant.card.booking.checkOutAt': '退房 {time}',
'assistant.card.booking.cancelled': '已取消',
'assistant.card.booking.inTrip': '屬於 {trip}',
'assistant.card.booking.range': '{from} 至 {to}',
'assistant.change.verb.addStay': '新增住宿',
'assistant.change.verb.updateStay': '變更住宿',
'assistant.change.verb.updateReservation': '變更預訂',
'assistant.tool.set_reservation_travelers': '變更旅客',
'assistant.tool.link_hotel_accommodation': '將飯店連結到住宿日期',
'assistant.tool.update_transport': '變更交通',
'assistant.tool.delete_transport': '刪除交通',
'assistant.tool.delete_accommodation': '取消住宿',
'assistant.tool.reorder_reservations': '重新排列預訂',
'assistant.tool.import_airtrail_flights': '從 AirTrail 匯入航班',
'assistant.step.bookings': '正在讀取預訂',
'assistant.step.stays': '正在讀取住宿',
'assistant.step.upcoming': '正在查看即將到來的預訂',
'assistant.followUp.bookingsOpen': '哪些預訂仍待確認?',
'assistant.followUp.bookingsNights': '每晚都有住處嗎?',
'assistant.followUp.bookingsConfirm': '確認待定的預訂',
};
export default assistantBooking;
+36 -1
View File
@@ -1,4 +1,39 @@
import type { TranslationStrings } from '../types';
const assistantBooking: TranslationStrings = {};
const assistantBooking: TranslationStrings = {
'assistant.card.booking.list': '预订',
'assistant.card.booking.open': '打开预订',
'assistant.card.booking.openStay': '在计划中显示',
'assistant.card.booking.more': '还有 {count} 项预订',
'assistant.card.booking.nights': '{count} 晚',
'assistant.card.booking.stops': '经停 {count} 次',
'assistant.card.booking.direct': '直达',
'assistant.card.booking.layoverAt': '在{place}停留 {duration}',
'assistant.card.booking.arrivesLater': '{count} 天后到达',
'assistant.card.booking.reference': '已保存预订号',
'assistant.card.booking.referenceHint': '预订号保存在预订中,而不是聊天里',
'assistant.card.booking.seat': '座位 {seat}',
'assistant.card.booking.platform': '{platform} 站台',
'assistant.card.booking.checkInAt': '入住 {time}',
'assistant.card.booking.checkOutAt': '退房 {time}',
'assistant.card.booking.cancelled': '已取消',
'assistant.card.booking.inTrip': '属于 {trip}',
'assistant.card.booking.range': '{from} 至 {to}',
'assistant.change.verb.addStay': '添加住宿',
'assistant.change.verb.updateStay': '更改住宿',
'assistant.change.verb.updateReservation': '更改预订',
'assistant.tool.set_reservation_travelers': '更改出行人',
'assistant.tool.link_hotel_accommodation': '将酒店关联到住宿日期',
'assistant.tool.update_transport': '更改交通',
'assistant.tool.delete_transport': '删除交通',
'assistant.tool.delete_accommodation': '取消住宿',
'assistant.tool.reorder_reservations': '重新排列预订',
'assistant.tool.import_airtrail_flights': '从 AirTrail 导入航班',
'assistant.step.bookings': '正在读取预订',
'assistant.step.stays': '正在读取住宿',
'assistant.step.upcoming': '正在查看即将到来的预订',
'assistant.followUp.bookingsOpen': '哪些预订还在待确认?',
'assistant.followUp.bookingsNights': '每晚都有住处吗?',
'assistant.followUp.bookingsConfirm': '确认待定的预订',
};
export default assistantBooking;
@@ -0,0 +1,25 @@
import { listAccommodationsRequestSchema, listReservationsRequestSchema } from './reservation-read.schema';
import { describe, expect, it } from 'vitest';
describe('listReservationsRequestSchema', () => {
it('RESREAD-001: takes a trip alone, a kind filter and a day', () => {
expect(listReservationsRequestSchema.safeParse({ tripId: 3 }).success).toBe(true);
expect(listReservationsRequestSchema.safeParse({ tripId: 3, kinds: ['transport'], dayId: 9 }).success).toBe(true);
expect(listReservationsRequestSchema.safeParse({ tripId: 3, kinds: ['booking', 'transport'] }).success).toBe(true);
});
it('RESREAD-002: refuses an unknown kind, an empty kind list, a bad id and a missing trip', () => {
expect(listReservationsRequestSchema.safeParse({ tripId: 3, kinds: ['hotel'] }).success).toBe(false);
expect(listReservationsRequestSchema.safeParse({ tripId: 3, kinds: [] }).success).toBe(false);
expect(listReservationsRequestSchema.safeParse({ tripId: 3, dayId: 0 }).success).toBe(false);
expect(listReservationsRequestSchema.safeParse({ kinds: ['booking'] }).success).toBe(false);
});
});
describe('listAccommodationsRequestSchema', () => {
it('RESREAD-003: takes the trip and nothing else is required', () => {
expect(listAccommodationsRequestSchema.safeParse({ tripId: 3 }).success).toBe(true);
expect(listAccommodationsRequestSchema.safeParse({ tripId: -1 }).success).toBe(false);
});
});
@@ -0,0 +1,37 @@
import { idSchema } from '../common/primitives.schema';
import { z } from 'zod';
/**
* The trip reads of the bookings an assistant makes over MCP. `get_trip_summary`
* carries every booking with its metadata, which the provider never sees (a
* flight number or a seat lives there); these two answer the bookings and the
* stays alone, with the readable details pulled out of the metadata.
*/
/** What a booking is, as a filter: getting there (a flight, a train, a hire car) or anything booked on the way. */
export const RESERVATION_READ_KINDS = ['transport', 'booking'] as const;
export type ReservationReadKind = (typeof RESERVATION_READ_KINDS)[number];
/** `list_reservations`: the trip's bookings, of one kind or both, on one day or all of them. */
export const listReservationsRequestSchema = z.object({
tripId: idSchema,
kinds: z
.array(z.enum(RESERVATION_READ_KINDS))
.min(1)
.max(RESERVATION_READ_KINDS.length)
.optional()
.describe(
'Only these kinds: "transport" (flights, trains, hire cars and other rides) or "booking" (hotels, tables, tickets). Both when left out.',
),
dayId: idSchema
.optional()
.describe(
'Only the bookings on this day: those that start or end on it, a stopover leg on it, or a stay checking in on it.',
),
});
export type ListReservationsRequest = z.infer<typeof listReservationsRequestSchema>;
/** `list_accommodations`: where the trip sleeps, stay by stay. */
export const listAccommodationsRequestSchema = z.object({ tripId: idSchema });
export type ListAccommodationsRequest = z.infer<typeof listAccommodationsRequestSchema>;