@curless/hotel-storefront 0.48.1 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
2
2
  import { MAX_GUESTS, isDay, nights as nightsBetween } from './dates.js';
3
3
  import { FACILITIES } from './facilities.js';
4
- import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
4
+ import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
5
5
  import { clockOf, minutesOf, startProblem, stayWords, windowWords } from './hourly.js';
6
6
  import { amountsIn, formatMoney, keepingAmounts, money, multiplyMoney, toMinor } from './money.js';
7
7
  import { namedExactly, roomNamesOf, roomTypeNamed, withTraditional } from './names.js';
@@ -298,7 +298,7 @@ const allTools = (n, v) => {
298
298
  // description says it too, as what the card is, never as an order: the
299
299
  // directory rule (tool-descriptions.test.ts) holds, and "first" is one of
300
300
  // its words, hence "the other way round".
301
- description: `${upFirst(catalogue(v))}, shown as a card for a guest to look through and pick one ${v.noun} to book. A trip of several days that wants its days planned (行程, 安排, an itinerary) is planned the other way round - the days, then for each part of the trip a ${v.noun} near where those days are spent, read up as text and set on the plan - and this card is no part of it, also when the guest was looking through it a moment before. Asked with no place - or a country of many cities - it answers with the places to choose from: regions, countries and cities, each with how many ${v.nounPlural} it has; \`all\` lists the ${v.nounPlural} themselves. \`city\` (a city or a country) or \`query\` answers with the ${v.nounPlural}: a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price, and a city usually holds several. With \`checkIn\` and \`checkOut\` every ${v.noun} is priced for those nights - its cheapest room that takes the party, \`rooms\` of it, for the whole stay with taxes - or marked as having no room for them. \`sort\` and \`maxNightly\` order and cap by that price, or by the from-price without dates, where the matches share one currency. \`brand\`, \`facilities\`, \`freeCancellation\`, \`breakfast\` and \`bed\` narrow the list, and the answer counts what each would leave. A long list comes a page at a time: the answer says how many more there are and the \`offset\` of the next page. Words or a place that match nothing are widened once, and the answer names what it widened to.${v.hints.search ? ` ${v.hints.search}` : ''}`,
301
+ description: `${upFirst(catalogue(v))}, shown as a card for a guest to look through and pick one ${v.noun} to book. It holds no plan of days: a day-by-day plan (行程, 安排, an itinerary) is a card of its own, laid out by the days, with a ${v.noun} for each part of the trip read up as text. Asked with no place - or a country of many cities - it answers with the places to choose from: regions, countries and cities, each with how many ${v.nounPlural} it has; \`all\` lists the ${v.nounPlural} themselves. \`city\` (a city or a country) or \`query\` answers with the ${v.nounPlural}: a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price, and a city usually holds several. With \`checkIn\` and \`checkOut\` every ${v.noun} is priced for those nights - its cheapest room that takes the party, \`rooms\` of it, for the whole stay with taxes - or marked as having no room for them. \`sort\` and \`maxNightly\` order and cap by that price, or by the from-price without dates, where the matches share one currency. \`brand\`, \`facilities\`, \`freeCancellation\`, \`breakfast\` and \`bed\` narrow the list, and the answer counts what each would leave. A long list comes a page at a time: the answer says how many more there are and the \`offset\` of the next page. Words or a place that match nothing are widened once, and the answer names what it widened to.${v.hints.search ? ` ${v.hints.search}` : ''}`,
302
302
  annotations: reads(`Browse ${catalogue(v)}`, open),
303
303
  inputSchema: {
304
304
  type: 'object',
@@ -431,7 +431,7 @@ const allTools = (n, v) => {
431
431
  // the guest for one question (robin, 2026-09-29, Opus in Claude.ai). The
432
432
  // reference apps do the same split: search without a card, present with
433
433
  // one.
434
- description: `Reads up to ${LOOK_UP_MAX} ${v.nounPlural} - a \`city\`'s (or a country's), or the ones named in \`${v.nounPlural}\` - and returns them as text, with nothing drawn and nothing shown to the guest: each one's id, area, rating, description and amenities, its add-ons with their ids and prices, and, given \`checkIn\` and \`checkOut\`, its rooms priced for those nights with taxes in. It is how the ${v.nounPlural} of a day-by-day plan are chosen - for each part of the trip, those near where its days are spent (a week in a big city has two or three parts, its far-out days side by side with a ${v.noun} near them); a city with a span of dates and 行程 is such a plan, also right after a list of ${v.nounPlural} - and the ids are the ones a comparison and a plan take.`,
434
+ description: `Reads up to ${LOOK_UP_MAX} ${v.nounPlural} - a \`city\`'s (or a country's), or the ones named in \`${v.nounPlural}\` - and returns them as text, with nothing drawn and nothing shown to the guest: each one's id, area, rating, description and amenities, its add-ons with their ids and prices, and, given \`checkIn\` and \`checkOut\`, its rooms priced for those nights with taxes in. Nothing it reads is shown, so the ${v.nounPlural} of a day-by-day plan - for each part of the trip, those near where its days are spent - can be read up before the plan is drawn; its ids are the ones a comparison and a plan take.`,
435
435
  annotations: reads(`Look up ${catalogue(v)}`, open),
436
436
  inputSchema: {
437
437
  type: 'object',
@@ -740,7 +740,7 @@ const allTools = (n, v) => {
740
740
  },
741
741
  {
742
742
  name: n.itinerary,
743
- description: `A day-by-day plan (行程) as one card - the card for a trip of several days, or for a guest who asks to have their days planned (a city with a span of dates and 行程 is one, also right after a list of ${v.nounPlural}): the days are laid out by where they are spent, and each part of the trip has a ${v.noun} near those days, chosen from the ${v.nounPlural} read up as text. A week in a big city is usually two or three stays: its far-out sights fall on neighbouring days, and those nights have a ${v.noun} near them, with the reason in that day's \`why\` (Beijing's Summer Palace, Yuanmingyuan, Fragrant Hills and the Peking and Tsinghua campuses - 颐和园, 圆明园, 香山, 北大清华 - are days side by side in the north-west); a day out of town, such as the Great Wall, is a day of its own, there and back. One ${v.noun} for the whole week is the plan when every day is within about 45 minutes of it, with the stay in two parts as the other way the reply names. Nothing here knows a sight's opening days: each day is drawn as it is given, with its date (many museums close on Mondays, the Palace Museum - 故宫 - among them). Each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
743
+ description: `A day-by-day plan (行程) as one card - the card for a trip of several days, or for days a guest asked to have planned (a city with a span of dates and 行程 is one): the days are laid out by where they are spent, and each part of the trip has a ${v.noun} near those days, chosen from the ${v.nounPlural} read up as text. A week in a big city is usually two or three stays: its far-out sights fall on neighbouring days, and those nights have a ${v.noun} near them, with the reason in that day's \`why\` (Beijing's Summer Palace, Yuanmingyuan, Fragrant Hills and the Peking and Tsinghua campuses - 颐和园, 圆明园, 香山, 北大清华 - are days side by side in the north-west); a day out of town, such as the Great Wall, is a day of its own, there and back; one ${v.noun} for the whole week suits days that are all within about 45 minutes of it. Nothing here knows a sight's opening days: each day is drawn as it is given, with its date (many museums close on Mondays, the Palace Museum - 故宫 - among them). Each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
744
744
  annotations: reads(v.market ? `Lay out an itinerary on ${v.brand}` : `Lay out a ${v.brand} itinerary`, open),
745
745
  inputSchema: {
746
746
  type: 'object',
@@ -983,6 +983,8 @@ const TRIP_SCHEMA = {
983
983
  subtotal: STR,
984
984
  taxesAndFees: STR,
985
985
  total: STR,
986
+ /** Which of a room type's rates a room named by its type was put in as - the text's first line. */
987
+ took: STR,
986
988
  /** Who is signed in, on every payload. */
987
989
  member: OBJ,
988
990
  /** What the tier took off THIS trip, and what it earns. */
@@ -1373,6 +1375,10 @@ export const buildOutputSchemas = (n) => ({
1373
1375
  inTrip: { type: 'number' },
1374
1376
  planned: { type: 'number' },
1375
1377
  dropped: ARR,
1378
+ /** The stays not booked yet, together - only when all are priced in one currency. */
1379
+ staysTotal: STR,
1380
+ /** What the text says of a long stay at one hotel, in the same words. */
1381
+ nudges: STRS,
1376
1382
  },
1377
1383
  required: ['view'],
1378
1384
  },
@@ -2815,8 +2821,8 @@ const suggestionsFor = async (deps, d) => {
2815
2821
  out.push({
2816
2822
  // The label IS what is sent: a chip that sends more than it says is
2817
2823
  // a chip that says something else in the guest's name.
2818
- label: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
2819
- say: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
2824
+ label: `Compare ${hotelName(hotel.name)} with the other ${hotelName(hotel.city)} hotels`,
2825
+ say: `Compare ${hotelName(hotel.name)} with the other ${hotelName(hotel.city)} hotels`,
2820
2826
  });
2821
2827
  }
2822
2828
  // The price order is not a suggestion any more: it is the list's own sort
@@ -2825,12 +2831,12 @@ const suggestionsFor = async (deps, d) => {
2825
2831
  const pick = (d.columns ?? []).find((c) => c.recommended);
2826
2832
  if (pick) {
2827
2833
  out.push({
2828
- label: `Rooms at ${pick.hotel.name}`,
2834
+ label: `Rooms at ${hotelName(pick.hotel.name)}`,
2829
2835
  ask: { tool: 'getHotel', args: { hotel: pick.hotel.id, ...on } },
2830
2836
  });
2831
2837
  out.push({
2832
- label: `Plan the days at ${pick.hotel.name}`,
2833
- say: `Plan the days at ${pick.hotel.name}`,
2838
+ label: `Plan the days at ${hotelName(pick.hotel.name)}`,
2839
+ say: `Plan the days at ${hotelName(pick.hotel.name)}`,
2834
2840
  });
2835
2841
  }
2836
2842
  }
@@ -3072,7 +3078,7 @@ const longStayNudges = async (deps, stays, days) => {
3072
3078
  const h = [...byArea.values()].find((o) => dayNames(day, areaWords(o)));
3073
3079
  return h
3074
3080
  ? [
3075
- `Day ${i + 1}${day.date ? ` (${day.date})` : ''} names ${h.area}, where ${h.name} (\`${h.id}\`) is.`,
3081
+ `Day ${i + 1}${day.date ? ` (${day.date})` : ''} names ${hotelName(h.area)}, where ${hotelName(h.name)} (\`${h.id}\`) is.`,
3076
3082
  ]
3077
3083
  : [];
3078
3084
  })
@@ -3081,14 +3087,18 @@ const longStayNudges = async (deps, stays, days) => {
3081
3087
  continue;
3082
3088
  const others = [...byArea.values()]
3083
3089
  .slice(0, OTHER_AREAS_MAX)
3084
- .map((h) => `${h.area} - ${h.name} (\`${h.id}\`)`);
3090
+ .map((h) => `${hotelName(h.area)} - ${hotelName(h.name)} (\`${h.id}\`)`);
3091
+ // The plan's own stay, in the same cleaned words.
3092
+ const at = hotelName(x.name);
3093
+ const area = hotelName(x.area);
3094
+ const city = hotelName(x.city);
3085
3095
  out.push(whole
3086
3096
  ? [
3087
- `All ${x.nights} nights are at ${x.name} (${x.area}). ${x.city} also has: ${others.join('; ')}.`,
3097
+ `All ${x.nights} nights are at ${at} (${area}). ${city} also has: ${others.join('; ')}.`,
3088
3098
  ...elsewhere,
3089
- `One hotel for the whole trip fits only when every day is within about 45 minutes of it. In your reply, name the days spent farther out than that and add one sentence on the other way - "it could also be two stays: ..." - those nights at the hotel here nearest them, and why; or say in one sentence why every day is within reach of ${x.area}.`,
3099
+ `One hotel for the whole trip fits only when every day is within about 45 minutes of it. In your reply, name the days spent farther out than that and add one sentence on the other way - "it could also be two stays: ..." - those nights at the hotel here nearest them, and why; or say in one sentence why every day is within reach of ${area}.`,
3090
3100
  ].join(' ')
3091
- : `${x.nights} nights are at ${x.name} (${x.area}). ${x.city} also has: ${others.join('; ')}. In your reply, name the planned days that are far from ${x.area} and offer moving those nights to the hotel here nearest them, with the reason in one sentence - or say why every day suits ${x.area}.`);
3101
+ : `${x.nights} nights are at ${at} (${area}). ${city} also has: ${others.join('; ')}. In your reply, name the planned days that are far from ${area} and offer moving those nights to the hotel here nearest them, with the reason in one sentence - or say why every day suits ${area}.`);
3092
3102
  }
3093
3103
  return out;
3094
3104
  };
@@ -3191,6 +3201,31 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3191
3201
  // 2026-10-12).
3192
3202
  const who = identity(deps, ctx, key);
3193
3203
  const startTime = str(args.startTime);
3204
+ // ⚠️ TAKING A ROOM OUT NEEDS NO ROOM FOR SALE. The lookups below are of
3205
+ // what is for sale, so a room that had gone since it went into the trip -
3206
+ // the stay a booking could not make - could not be taken out of it
3207
+ // (2026-10-14). Nor could a room by the hour once its window had passed:
3208
+ // the start was checked against today's window first, and every line of a
3209
+ // trip must be removable (2026-10-27 audit, F6). The line in the trip is the
3210
+ // one asked about - by its nights, or a room by the hour by its start.
3211
+ if (args.rooms !== undefined && clampCount(args.rooms, MAX_ROOMS_PER_LINE) === 0) {
3212
+ const asked = asStay(args);
3213
+ const start = startTime ? minutesOf(startTime) : undefined;
3214
+ const held = deps.trips
3215
+ .read(store)
3216
+ .find((l) => l.kind === 'room' &&
3217
+ l.hotelId === hotelId &&
3218
+ l.roomId === roomRef &&
3219
+ (l.hourly
3220
+ ? start !== undefined &&
3221
+ minutesOf(l.hourly.start) === start &&
3222
+ (!asked || l.stay.checkIn === asked.checkIn)
3223
+ : !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
3224
+ if (held) {
3225
+ deps.trips.set(store, unset(held));
3226
+ return currentTrip(deps, key, who, store);
3227
+ }
3228
+ }
3194
3229
  const today = (startTime || !asStay(args)
3195
3230
  ? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
3196
3231
  : undefined);
@@ -3269,23 +3304,6 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3269
3304
  : 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
3270
3305
  };
3271
3306
  }
3272
- // ⚠️ TAKING A ROOM OUT NEEDS NO ROOM FOR SALE. The lookup below is of
3273
- // what is for sale, so a room that had gone since it went into the trip -
3274
- // the stay a booking could not make - could not be taken out of it
3275
- // (2026-10-14). The line in the trip is the one asked about.
3276
- if (args.rooms !== undefined && clampCount(args.rooms, MAX_ROOMS_PER_LINE) === 0) {
3277
- const held = deps.trips
3278
- .read(store)
3279
- .find((l) => l.kind === 'room' &&
3280
- l.hotelId === hotelId &&
3281
- l.roomId === roomRef &&
3282
- l.stay.checkIn === stay.checkIn &&
3283
- l.stay.checkOut === stay.checkOut);
3284
- if (held) {
3285
- deps.trips.set(store, unset(held));
3286
- return currentTrip(deps, key, identity(deps, ctx, key), store);
3287
- }
3288
- }
3289
3307
  // The backend owns the calendar - it knows each hotel's own timezone, and a
3290
3308
  // second opinion computed from this process's clock would disagree with it
3291
3309
  // for eight hours a day.
@@ -3370,7 +3388,11 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3370
3388
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3371
3389
  });
3372
3390
  const now = await currentTrip(deps, key, identity(deps, ctx, key), store);
3373
- return took ? { ...now, text: `${took}\n${now.text}` } : now;
3391
+ // Which rate was taken, in the structure too and in the same words - see
3392
+ // the itinerary's `nudges` (2026-10-27 audit, F11).
3393
+ return took
3394
+ ? { ...now, structured: { ...now.structured, took }, text: `${took}\n${now.text}` }
3395
+ : now;
3374
3396
  };
3375
3397
  /**
3376
3398
  * Books the rooms in one slot of the trip store for a named guest - THE ONE
@@ -4370,7 +4392,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4370
4392
  const extras = await deps.backend.extrasFor(h.id, stay);
4371
4393
  const rooms = (detail.rooms ?? []).filter((r) => r.roomsLeft >= roomsWanted);
4372
4394
  blocks.push([
4373
- `### ${h.name} · id \`${h.id}\``,
4395
+ `### ${hotelName(h.name)} · id \`${h.id}\``,
4374
4396
  [
4375
4397
  [h.area, h.city, h.country].filter(Boolean).join(', '),
4376
4398
  h.brand,
@@ -4386,7 +4408,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4386
4408
  : rooms.length
4387
4409
  ? [
4388
4410
  `Rooms for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}:`,
4389
- ...rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`),
4411
+ ...rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`),
4390
4412
  ].join('\n')
4391
4413
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
4392
4414
  extras.length
@@ -4721,9 +4743,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4721
4743
  url: step.url,
4722
4744
  ...(step.kind === 'confirm' ? { booking: bookingForCard(step.booking) } : {}),
4723
4745
  },
4746
+ // ⚠️ THE LINK STAYS ON THE CARD (2026-10-27 audit, F9). Only the card
4747
+ // calls this, and the card hands this text to the model as context - so
4748
+ // UnionPay's own page, a link nobody here knows the life of, went into
4749
+ // the conversation. `url` is in the structure, for the card alone.
4724
4750
  text: step.kind === 'enroll'
4725
- ? `No UnionPay card is on file for that email yet. UnionPay's page to add one: ${step.url} - once it is added, paying with UnionPay again opens the confirmation.`
4726
- : `UnionPay's page to confirm ${step.booking.confirmationCode} on: ${step.url} - the hotel is asked once UnionPay has confirmed it.`,
4751
+ ? `No UnionPay card is on file for that email yet. UnionPay's page to add one is on the card - once it is added, paying with UnionPay again opens the confirmation.`
4752
+ : `UnionPay's page to confirm ${step.booking.confirmationCode} on is on the card - the hotel is asked once UnionPay has confirmed it.`,
4727
4753
  };
4728
4754
  }
4729
4755
  if (name === t.previewChange || name === t.changeBooking) {
@@ -5146,17 +5172,37 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5146
5172
  const stayItems = plan.days
5147
5173
  .flatMap((day) => day.items)
5148
5174
  .filter((x) => x.kind === 'hotel' && (x.night ?? 1) === 1 && x.checkIn && x.roomId);
5149
- const stays = stayItems.map((x, i) => `Stay ${i + 1}: ${x.name} (\`${x.id}\`), room \`${x.roomId}\`${x.roomName ? ` (${x.roomName})` : ''}, ${x.checkIn} to ${x.checkOut}${x.nights ? ` (${x.nights} ${x.nights === 1 ? 'night' : 'nights'})` : ''}${x.booked ? ` - booked, ${x.booked}` : x.inTrip ? ' - in the trip' : ''}`);
5175
+ // Each stay's rate says its terms where they bind - see `ItineraryItem.refundable`.
5176
+ const termsOf = (x) => [
5177
+ x.refundable === false ? 'non-refundable' : '',
5178
+ x.payment === 'prepaid' ? 'paid in advance' : '',
5179
+ ]
5180
+ .filter(Boolean)
5181
+ .join(', ');
5182
+ const stays = stayItems.map((x, i) => `Stay ${i + 1}: ${hotelName(x.name)} (\`${x.id}\`), room \`${x.roomId}\`${x.roomName ? ` (${hotelName(x.roomName)}${termsOf(x) ? `; ${termsOf(x)}` : ''})` : termsOf(x) ? ` (${termsOf(x)})` : ''}, ${x.checkIn} to ${x.checkOut}${x.nights ? ` (${x.nights} ${x.nights === 1 ? 'night' : 'nights'})` : ''}${x.booked ? ` - booked, ${x.booked}` : x.inTrip ? ' - in the trip' : ''}`);
5183
+ // ⚠️ IN BOTH, IN THE SAME WORDS (2026-10-27 audit, F11): a host that hands
5184
+ // the model `structuredContent` rather than the text never saw what the
5185
+ // text said of a long stay. The model's part of the answer is the same on
5186
+ // either side.
5187
+ const nudges = stays.length ? await longStayNudges(deps, stayItems, plan.days) : [];
5150
5188
  return {
5151
- structured: { view: 'itinerary', ...common(deps, key), ...plan, gap },
5189
+ structured: {
5190
+ view: 'itinerary',
5191
+ ...common(deps, key),
5192
+ ...plan,
5193
+ gap,
5194
+ ...(nudges.length ? { nudges } : {}),
5195
+ },
5152
5196
  text: [
5153
5197
  `**${plan.title}** · ${plan.days.length} ${plan.days.length === 1 ? 'day' : 'days'}. ${gap}`,
5154
5198
  ...(stays.length
5155
5199
  ? [
5156
5200
  `Priced stays (${plan.stay?.guests ?? 2} guests; ${t.addToTrip} takes each one's hotel id, room id and dates):`,
5157
5201
  ...stays,
5158
- // ⚠️ IN THE TEXT ONLY - not in `structured`, not in the schema.
5159
- ...(await longStayNudges(deps, stayItems, plan.days)),
5202
+ ...(plan.staysTotal
5203
+ ? [`Together, the stays not booked yet: ${plan.staysTotal}.`]
5204
+ : []),
5205
+ ...nudges,
5160
5206
  ]
5161
5207
  : []),
5162
5208
  ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),