mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +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,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;
|
||||
|
||||
Reference in New Issue
Block a user