mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(assistant): booking and stay cards, and a read of the bookings with their details
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user