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

This commit is contained in:
Maurice
2026-10-22 15:50:50 +02:00
parent 51e5dbef03
commit b391ddcc43
67 changed files with 6336 additions and 103 deletions
@@ -0,0 +1,125 @@
import {
BOOKING_CHANGED_FIELDS,
bookingCardSchema,
type BookingItem,
type BookingLeg,
type BookingStay,
type BookingTicket,
} from './booking.card';
import { BOOKING_APPLIED_ENTITIES, BOOKING_VERB_KEYS } from './booking.contract';
import { storedTrekCardSchema, trekCardSchema } from './index';
import { describe, expect, it } from 'vitest';
const day = { id: 31, number: 1, date: '2026-06-03' };
const LEG: BookingLeg = {
carrier: 'Lufthansa',
number: 'LH 400',
departs: '08:15',
departsDay: day,
arrives: '11:05',
arrivesDay: day,
minutes: 530,
seat: '12A',
platform: null,
};
const TICKET: BookingTicket = {
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57 },
{ name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 },
],
legs: [LEG],
minutes: 530,
mappable: true,
};
const STAY: BookingStay = {
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: 'Invalidenstraße 38',
lat: 52.53,
lng: 13.38,
photoKey: 'ChIJmercure',
checkIn: { day, from: '15:00', until: '20:00' },
checkOut: { day: { id: 33, number: 3, date: '2026-06-05' }, by: '11:00' },
nights: 2,
};
const ticket: BookingItem = {
tripId: 4,
tripTitle: null,
reservationId: 501,
type: 'flight',
title: 'Frankfurt to New York',
status: 'confirmed',
needsReview: false,
hasReference: true,
price: { amount: 420, currency: 'EUR' },
travelers: [{ id: 7, name: 'mia', avatarKey: 'mia.png' }],
moreTravelers: 0,
changed: [],
body: TICKET,
};
const stay: BookingItem = {
...ticket,
reservationId: null,
type: 'hotel',
status: null,
body: STAY,
};
const card = (items: unknown[], extra: Record<string, unknown> = {}) => ({
kind: 'booking',
change: 'none',
items,
more: 0,
...extra,
});
describe('bookingCardSchema', () => {
it('CARDBOOK-001: takes a ticket, a stay and a venue in one card, and the union reads it', () => {
const body = { shape: 'venue', day, starts: '19:00', ends: null, location: 'Borchardt', placeId: 13 };
const venue = { ...ticket, body: { ...body, lat: null, lng: null } };
expect(bookingCardSchema.safeParse(card([ticket, stay, venue])).success).toBe(true);
expect(trekCardSchema.safeParse(card([ticket], { change: 'update' })).success).toBe(true);
});
it('CARDBOOK-002: holds one to eight bookings and counts the rest', () => {
expect(bookingCardSchema.safeParse(card([])).success).toBe(false);
expect(bookingCardSchema.safeParse(card(Array.from({ length: 9 }, () => ticket))).success).toBe(false);
expect(bookingCardSchema.safeParse(card([ticket], { more: -1 })).success).toBe(false);
});
it('CARDBOOK-003: refuses a picture address or a coordinate key as photo key, and a clock past 23:59', () => {
const photo = (photoKey: string) => card([{ ...stay, body: { ...STAY, photoKey } }]);
expect(bookingCardSchema.safeParse(photo('https://maps.example/x.jpg')).success).toBe(false);
expect(bookingCardSchema.safeParse(photo('coords:52.5,13.4')).success).toBe(false);
const late = { ...ticket, body: { ...TICKET, legs: [{ ...LEG, departs: '24:00' }] } };
expect(bookingCardSchema.safeParse(card([late])).success).toBe(false);
});
it('CARDBOOK-004: refuses a ticket without a leg, an unknown shape, a lowercase code and a currency in words', () => {
expect(bookingCardSchema.safeParse(card([{ ...ticket, body: { ...TICKET, legs: [] } }])).success).toBe(false);
expect(bookingCardSchema.safeParse(card([{ ...ticket, body: { shape: 'cruise' } }])).success).toBe(false);
const code = { ...ticket, body: { ...TICKET, stops: [{ name: 'Frankfurt', code: 'fra', lat: null, lng: null }] } };
expect(bookingCardSchema.safeParse(card([code])).success).toBe(false);
const words = { ...ticket, price: { amount: 5, currency: 'euro' } };
expect(bookingCardSchema.safeParse(card([words])).success).toBe(false);
});
it('CARDBOOK-005: a changed field must be one the card knows; a broken card reads as none, never the message', () => {
expect(bookingCardSchema.safeParse(card([{ ...ticket, changed: [...BOOKING_CHANGED_FIELDS] }])).success).toBe(true);
expect(bookingCardSchema.safeParse(card([{ ...ticket, changed: ['seat'] }])).success).toBe(false);
expect(storedTrekCardSchema.parse(card([{ ...ticket, title: '' }]))).toBeNull();
});
it('CARDBOOK-006: the bookings add their verbs and the stay as an applied entity', () => {
expect(BOOKING_VERB_KEYS).toEqual(['addStay', 'updateStay', 'updateReservation']);
expect(BOOKING_APPLIED_ENTITIES).toEqual(['accommodation']);
});
});
+148 -3
View File
@@ -1,8 +1,153 @@
import { idSchema } from '../../common/primitives.schema';
import { RESERVATION_STATUSES } from '../../reservation/reservation-types';
import {
cardClockSchema,
cardCountSchema,
cardDaySchema,
cardLatSchema,
cardLngSchema,
cardMoneySchema,
cardPersonSchema,
cardPhotoKeySchema,
cardText,
} from './card-parts';
import { z } from 'zod';
/**
* Bookings: flights, trains, stays and other reservations. Only its kind so far; the booking cards
* give it its fields.
* Bookings in the assistant chat: a flight, a train, a hire car (a ticket), a
* hotel or any stay (a stay), a table, a show, a tour or a car park (a
* venue). One card per tool call holds the bookings it is about, at most
* eight, with the rest counted; an e-ticket file with a flight, a hotel and a
* flight back stays one itinerary. A booking's reference never travels, only
* that it has one: the chat is stored and can be shared, and the planner
* blurs it too.
*/
export const bookingCardSchema = z.object({ kind: z.literal('booking') });
/** The type column is free text: any short key passes, the client draws an unknown one as `other`. */
const bookingTypeSchema = z.string().regex(/^[a-z_]{1,30}$/);
const lat = cardLatSchema.nullable();
const lng = cardLngSchema.nullable();
/** One end or stop of a route: the airport or station, its code where it has one. */
const bookingStopSchema = z.object({
name: cardText(80),
code: z
.string()
.regex(/^[A-Z0-9]{2,5}$/)
.nullable(),
lat,
lng,
});
/** One segment; a stopover's arrival lives only here (a stop's endpoint carries the onward departure). */
const bookingLegSchema = z.object({
carrier: cardText(60).nullable(),
number: cardText(20).nullable(),
departs: cardClockSchema.nullable(),
departsDay: cardDaySchema.nullable(),
arrives: cardClockSchema.nullable(),
arrivesDay: cardDaySchema.nullable(),
/** Only when both ends know their zone. */
minutes: z.number().int().min(1).max(10_080).nullable(),
seat: cardText(20).nullable(),
platform: cardText(20).nullable(),
});
const ticketBodySchema = z.object({
shape: z.literal('ticket'),
/** From, the stops, to; empty when no route is known (a hire car, a taxi). */
stops: z.array(bookingStopSchema).max(9),
/** One per segment between the stops, else exactly one. */
legs: z.array(bookingLegSchema).min(1).max(8),
/** First departure to last arrival. */
minutes: z.number().int().min(1).max(20_160).nullable(),
/** A saved booking with two stops that have coordinates: the plan can draw it. */
mappable: z.boolean(),
});
const stayBodySchema = z.object({
shape: z.literal('stay'),
/** Null while a create or a new place waits. */
accommodationId: idSchema.nullable(),
placeId: idSchema.nullable(),
address: cardText(200).nullable(),
lat,
lng,
/** A Google place id for TREK's photo proxy; never a picture address. */
photoKey: cardPhotoKeySchema.nullable(),
checkIn: z
.object({ day: cardDaySchema, from: cardClockSchema.nullable(), until: cardClockSchema.nullable() })
.nullable(),
checkOut: z.object({ day: cardDaySchema, by: cardClockSchema.nullable() }).nullable(),
nights: z.number().int().min(0).max(366).nullable(),
});
const venueBodySchema = z.object({
shape: z.literal('venue'),
day: cardDaySchema.nullable(),
starts: cardClockSchema.nullable(),
ends: cardClockSchema.nullable(),
/** The location text, else the linked place's name. */
location: cardText(200).nullable(),
placeId: idSchema.nullable(),
lat,
lng,
});
/** What an update changes, as the card shows it (its derived values, not the raw arguments). */
export const BOOKING_CHANGED_FIELDS = [
'title',
'type',
'status',
'day',
'time',
'route',
'stay',
'checkIn',
'checkOut',
'location',
'price',
'travelers',
'reference',
] as const;
export type BookingChangedField = (typeof BOOKING_CHANGED_FIELDS)[number];
export const bookingItemSchema = z.object({
tripId: idSchema,
/** Only on a row of another trip (the upcoming bookings across every trip). */
tripTitle: cardText(120).nullable(),
reservationId: idSchema.nullable(),
type: bookingTypeSchema,
title: cardText(200),
/** Null for a transit journey, a bare stay, or a status not known. */
status: z.enum(RESERVATION_STATUSES).nullable(),
needsReview: z.boolean(),
/** A confirmation number exists; the number itself never travels. */
hasReference: z.boolean(),
price: cardMoneySchema.nullable(),
travelers: z.array(cardPersonSchema).max(8),
moreTravelers: z.number().int().min(0).max(500),
changed: z.array(z.enum(BOOKING_CHANGED_FIELDS)).max(BOOKING_CHANGED_FIELDS.length),
body: z.discriminatedUnion('shape', [ticketBodySchema, stayBodySchema, venueBodySchema]),
});
export type BookingItem = z.infer<typeof bookingItemSchema>;
export type BookingBody = BookingItem['body'];
export type BookingTicket = Extract<BookingBody, { shape: 'ticket' }>;
export type BookingStay = Extract<BookingBody, { shape: 'stay' }>;
export type BookingVenue = Extract<BookingBody, { shape: 'venue' }>;
export type BookingLeg = BookingTicket['legs'][number];
export type BookingStop = BookingTicket['stops'][number];
/** What a held write does to its bookings; `none` on an answer and on an applied change. */
export const BOOKING_CHANGES = ['none', 'create', 'update', 'remove'] as const;
export const bookingCardSchema = z.object({
kind: z.literal('booking'),
change: z.enum(BOOKING_CHANGES),
items: z.array(bookingItemSchema).min(1).max(8),
/** Bookings the answer held past the eight shown. */
more: cardCountSchema,
});
export type BookingCard = z.infer<typeof bookingCardSchema>;
@@ -10,5 +10,7 @@ export * from './booking.card';
* or entity is added here and nowhere else.
*/
export const BOOKING_CARD_SCHEMAS = [bookingCardSchema] as const;
export const BOOKING_VERB_KEYS = [] as const;
export const BOOKING_APPLIED_ENTITIES = [] as const;
/** addReservation and importBooking sit in the core list already. */
export const BOOKING_VERB_KEYS = ['addStay', 'updateStay', 'updateReservation'] as const;
/** 'reservation' is a core entity already; a stay is its own row. */
export const BOOKING_APPLIED_ENTITIES = ['accommodation'] as const;