diff --git a/server/scripts/strict-baseline.json b/server/scripts/strict-baseline.json index 1d0fa82dc..2977cda47 100644 --- a/server/scripts/strict-baseline.json +++ b/server/scripts/strict-baseline.json @@ -101,6 +101,5 @@ "src/nest/roadtrip/roadtrip-router.service.ts": 12, "src/nest/settings/settings.service.ts": 1, "src/nest/storage/drivers/s3.driver.ts": 1, - "src/nest/trips/trips.controller.ts": 1, "src/nest/trips/trips.service.ts": 1 } diff --git a/server/src/nest/assistant/cards/booking/booking-answer.ts b/server/src/nest/assistant/cards/booking/booking-answer.ts index f4ed23a71..dc924eaf3 100644 --- a/server/src/nest/assistant/cards/booking/booking-answer.ts +++ b/server/src/nest/assistant/cards/booking/booking-answer.ts @@ -16,7 +16,11 @@ 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); + 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. */ diff --git a/server/src/nest/assistant/cards/booking/index.ts b/server/src/nest/assistant/cards/booking/index.ts index 893bb4346..f8c48c006 100644 --- a/server/src/nest/assistant/cards/booking/index.ts +++ b/server/src/nest/assistant/cards/booking/index.ts @@ -1,5 +1,5 @@ -import { IMPORT_BOOKING_TOOL } from '../../assistant-tools.constants'; import type { TripCard } from '../../assistant-context'; +import { IMPORT_BOOKING_TOOL } from '../../assistant-tools.constants'; import type { DomainCards, PreviewContext, ToolArgs } from '../card-types'; import { argId, clipped, idIn } from '../card-values'; import { BOOKING_ANSWERS } from './booking-answer'; diff --git a/server/src/nest/assistant/cards/budget/budget-describe.ts b/server/src/nest/assistant/cards/budget/budget-describe.ts index 3f07e9066..12a178727 100644 --- a/server/src/nest/assistant/cards/budget/budget-describe.ts +++ b/server/src/nest/assistant/cards/budget/budget-describe.ts @@ -28,7 +28,10 @@ function expenseName(ctx: PreviewContext, args: ToolArgs): string { /** Who shares a stored expense, by name. */ function sharedBy(ctx: PreviewContext, row: Row | undefined): string { - return memberNames(ctx, rowsOf(row?.members).map((member) => member.user_id)); + return memberNames( + ctx, + rowsOf(row?.members).map((member) => member.user_id), + ); } /** "Ana → Ben", from the two people of a payment. */ diff --git a/server/src/nest/assistant/cards/budget/budget-values.ts b/server/src/nest/assistant/cards/budget/budget-values.ts index f910d484c..4f68da615 100644 --- a/server/src/nest/assistant/cards/budget/budget-values.ts +++ b/server/src/nest/assistant/cards/budget/budget-values.ts @@ -117,7 +117,11 @@ export function splitOf( max: number, ): Pick { if (members.length === 0) return { split: 'none', shares: [], moreShares: 0 }; - const equal = splitEqualShares(toMinor(total), members.map((member) => ({ user_id: member.id })), itemId); + const equal = splitEqualShares( + toMinor(total), + members.map((member) => ({ user_id: member.id })), + itemId, + ); const shares = members.map((member) => ({ person: personFrom(card, member.id, member.name, member.avatarUrl), amount: member.amount ?? (equal[member.id] ?? 0) / 100, diff --git a/server/src/nest/assistant/cards/collection/index.ts b/server/src/nest/assistant/cards/collection/index.ts index 5353d0198..b3f6a25ae 100644 --- a/server/src/nest/assistant/cards/collection/index.ts +++ b/server/src/nest/assistant/cards/collection/index.ts @@ -73,12 +73,15 @@ export const COLLECTION_CARDS: DomainCards = { list_collections: { answer: listsAnswer }, find_place_in_collections: { answer: membershipAnswer }, ...WRITE_CARDS, - create_collection: { ...WRITE_CARDS.create_collection, undo: DELETE_CREATED_LIST }, + create_collection: { ...writeCards('create_collection', WRITES.create_collection), undo: DELETE_CREATED_LIST }, save_place_to_collection: { - ...WRITE_CARDS.save_place_to_collection, + ...writeCards('save_place_to_collection', WRITES.save_place_to_collection), undo: DELETE_SAVED_PLACE, argPolicy: NO_IMAGE, }, - update_collection_place: { ...WRITE_CARDS.update_collection_place, argPolicy: NO_IMAGE }, + update_collection_place: { + ...writeCards('update_collection_place', WRITES.update_collection_place), + argPolicy: NO_IMAGE, + }, }, }; diff --git a/server/src/nest/assistant/cards/packing/packing-changes.ts b/server/src/nest/assistant/cards/packing/packing-changes.ts index 8a633ef73..d770290f9 100644 --- a/server/src/nest/assistant/cards/packing/packing-changes.ts +++ b/server/src/nest/assistant/cards/packing/packing-changes.ts @@ -24,12 +24,7 @@ import { type ListItem, type PackingTier, } from './packing-rows'; -import { - resolvePackedState, - type PackingCardBag, - type PackingCardItem, - type PackingListCard, -} from '@trek/shared'; +import { resolvePackedState, type PackingCardBag, type PackingCardItem, type PackingListCard } from '@trek/shared'; /** * The card each packing change shows inside its change card, from the call's @@ -330,37 +325,56 @@ export function packingAssigneesCard(ctx: PreviewContext, args: ToolArgs, loaded const before = rowsOf(recordOf(recordOf(loaded).assignees)[raw]).flatMap((row) => positiveId(row.user_id) ?? []); const inside = tripPacking(ctx.card).filter((item) => !item.placeholder && item.category === raw); const personal = inside.length > 0 && inside.every((item) => item.isPrivate); - return packingCard({ type: 'trip' }, 'category', [], [ - { - key: { by: 'category', name, slot: slotOf(ctx.card, raw, personal) }, - assignees: peopleOf(ctx.card, chosen, 6), - assigneesRemoved: peopleOf(ctx.card, before.filter((id) => !chosen.includes(id)), 6), - items: [], - count: inside.length, - packed: inside.filter((item) => item.checked).length, - }, - ]); + return packingCard( + { type: 'trip' }, + 'category', + [], + [ + { + key: { by: 'category', name, slot: slotOf(ctx.card, raw, personal) }, + assignees: peopleOf(ctx.card, chosen, 6), + assigneesRemoved: peopleOf( + ctx.card, + before.filter((id) => !chosen.includes(id)), + 6, + ), + items: [], + count: inside.length, + packed: inside.filter((item) => item.checked).length, + }, + ], + ); } /** A bag head on a card of its own: what a change to one bag shows. */ function bagCard(card: TripCard, bag: PackingCardBag): PackingListCard { const inside = tripPacking(card).filter((item) => !item.placeholder && bag.id !== null && item.bagId === bag.id); - return packingCard({ type: 'trip' }, 'bag', [bag], [ - { - key: { by: 'bag', bagKey: bag.key }, - assignees: [], - assigneesRemoved: [], - items: [], - count: inside.length, - packed: inside.filter((item) => item.checked).length, - }, - ]); + return packingCard( + { type: 'trip' }, + 'bag', + [bag], + [ + { + key: { by: 'bag', bagKey: bag.key }, + assignees: [], + assigneesRemoved: [], + items: [], + count: inside.length, + packed: inside.filter((item) => item.checked).length, + }, + ], + ); } /** The bag a call names (`bagId`), as the lookup read it. */ function namedBag(card: TripCard, args: ToolArgs, loaded: unknown): PackingCardBag | null { const bagId = argId(args, 'bagId'); - return bagId === null ? null : bagOf(card, rawBags(loaded).find((bag) => bag.id === bagId)); + return bagId === null + ? null + : bagOf( + card, + rawBags(loaded).find((bag) => bag.id === bagId), + ); } /** `create_packing_bag`: the new bag's head, in its colour (indigo without one) and with its limit. */ diff --git a/server/src/nest/assistant/cards/packing/packing-rows.ts b/server/src/nest/assistant/cards/packing/packing-rows.ts index 935bf3ace..949b9928a 100644 --- a/server/src/nest/assistant/cards/packing/packing-rows.ts +++ b/server/src/nest/assistant/cards/packing/packing-rows.ts @@ -204,7 +204,11 @@ export function slotOf(card: TripCard | null, category: string | null, personal: /** The people of an assignee list (`[{ user_id }]`), as trip members. */ export function assigneesOf(card: TripCard, raw: unknown): CardPerson[] { - return peopleOf(card, rowsOf(raw).flatMap((row) => positiveId(row.user_id) ?? []), 6); + return peopleOf( + card, + rowsOf(raw).flatMap((row) => positiveId(row.user_id) ?? []), + 6, + ); } /** One bag as `list_packing_bags` answers it. */ @@ -302,7 +306,11 @@ export function packingGroups( items: readonly ListItem[], options: GroupOptions, ): { groups: PackingCardGroup[]; moreGroups: number } { - const buckets = bucketsOf(card, items.filter((item) => !item.placeholder), options); + const buckets = bucketsOf( + card, + items.filter((item) => !item.placeholder), + options, + ); let left = options.maxRows ?? MAX_PACKING_ROWS; const perGroup = options.fillSingleGroup && buckets.length === 1 ? left : ROWS_PER_GROUP; const groups = buckets.slice(0, MAX_CARD_GROUPS).map((bucket): PackingCardGroup => { diff --git a/server/src/nest/assistant/cards/packing/todo-cards.ts b/server/src/nest/assistant/cards/packing/todo-cards.ts index 4515a2cdf..5db1a97c0 100644 --- a/server/src/nest/assistant/cards/packing/todo-cards.ts +++ b/server/src/nest/assistant/cards/packing/todo-cards.ts @@ -170,7 +170,11 @@ function todoAssigneesCard(ctx: PreviewContext, args: ToolArgs, loaded: TodoAssi category, slot: todoSlot(ctx.card, args.categoryName), assignees: peopleOf(ctx.card, chosen, 6), - assigneesRemoved: peopleOf(ctx.card, before.filter((id) => !chosen.includes(id)), 6), + assigneesRemoved: peopleOf( + ctx.card, + before.filter((id) => !chosen.includes(id)), + 6, + ), items: [], count: inList.length, open: inList.filter((todo) => !todo.checked).length, diff --git a/server/src/nest/assistant/cards/roadtrip/roadtrip-describe.ts b/server/src/nest/assistant/cards/roadtrip/roadtrip-describe.ts index bacb13fbc..1d180536a 100644 --- a/server/src/nest/assistant/cards/roadtrip/roadtrip-describe.ts +++ b/server/src/nest/assistant/cards/roadtrip/roadtrip-describe.ts @@ -113,7 +113,7 @@ export function describeSettings(ctx: PreviewContext, args: Args, loaded: SavedS after: settingText(change.after), }); } - return { subject: clip(ctx.card.title), rows, dayId: null, placeIds: [] }; + return { subject: clip(ctx.card.title), rows, dayId: null, placeIds: [], tool: 'update_roadtrip_settings' }; } export function describeDayEnd(ctx: PreviewContext, args: Args): Described { @@ -129,7 +129,9 @@ export function describeImportRoute(ctx: PreviewContext, args: Args): Described const names = Array.isArray(args.stops) ? args.stops.map((stop: unknown) => text(recordOf(stop).name)).filter((name) => name !== '') : []; - const rows = dayRows(ctx, dayId).add('quantity', String(names.length)).add('route', clip(routeText(names))); + const rows = dayRows(ctx, dayId) + .add('quantity', String(names.length)) + .add('route', clip(routeText(names))); const ends = names.length > 0 ? routeText([names[0] ?? '', names[names.length - 1] ?? '']) : ''; return { subject: ends, rows, dayId, placeIds: [] }; } diff --git a/server/src/nest/assistant/cards/tour/tour-change.cards.ts b/server/src/nest/assistant/cards/tour/tour-change.cards.ts index d4611758c..e078d3b8f 100644 --- a/server/src/nest/assistant/cards/tour/tour-change.cards.ts +++ b/server/src/nest/assistant/cards/tour/tour-change.cards.ts @@ -1,6 +1,5 @@ import type { CardReader, PreviewContext, ToolArgs } from '../card-types'; import { canonicalJson, clipped, finite, recordOf, type Row } from '../card-values'; -import { hikeCard } from './tour-route.cards'; import { argText, climbOf, @@ -19,6 +18,7 @@ import { straightMetres, whole, } from './tour-card-values'; +import { hikeCard } from './tour-route.cards'; import { hikingPreferencesOf, TOUR_ROUTE_LIMITS, diff --git a/server/src/nest/assistant/cards/vacay/vacay-answer.ts b/server/src/nest/assistant/cards/vacay/vacay-answer.ts index 0e97afb5a..b9e1a67a5 100644 --- a/server/src/nest/assistant/cards/vacay/vacay-answer.ts +++ b/server/src/nest/assistant/cards/vacay/vacay-answer.ts @@ -104,7 +104,11 @@ export const VACAY_ANSWER_CARDS = { const stats = statsOf(input.value); if (!stats || stats.length === 0) return null; const viewerId = input.card.viewerId; - const people = peopleOf(stats.map((row) => row.personId), knownPeople(null, [], stats), viewerId); + const people = peopleOf( + stats.map((row) => row.personId), + knownPeople(null, [], stats), + viewerId, + ); const order = new Map(people.map((person, index) => [person.id, index])); const balances = keepKnown(stats, people) .sort((a, b) => (order.get(a.personId) ?? 0) - (order.get(b.personId) ?? 0)) diff --git a/server/src/nest/assistant/cards/vacay/vacay-change.ts b/server/src/nest/assistant/cards/vacay/vacay-change.ts index ef587bc83..dbc612f2c 100644 --- a/server/src/nest/assistant/cards/vacay/vacay-change.ts +++ b/server/src/nest/assistant/cards/vacay/vacay-change.ts @@ -96,7 +96,10 @@ function toggleProposal(viewerId: number, args: ToolArgs, loaded: VacayLoaded, d const was = existing ? counted(existing.kind, existing.fraction) : 0; const will = outcome === 'remove' ? 0 : outcome === 'blocked' ? was : counted(kind, fraction); const year = Number(date.slice(0, 4)); - const periodYear = periodOf(date, loaded.stats.find((row) => row.personId === personId && row.year === year)); + const periodYear = periodOf( + date, + loaded.stats.find((row) => row.personId === personId && row.year === year), + ); const counts = kind === 'vacation' && (outcome === 'add' || outcome === 'convert'); return { date, diff --git a/server/src/nest/assistant/cards/vacay/vacay-frame.ts b/server/src/nest/assistant/cards/vacay/vacay-frame.ts index f7b0019b0..4256e0a0c 100644 --- a/server/src/nest/assistant/cards/vacay/vacay-frame.ts +++ b/server/src/nest/assistant/cards/vacay/vacay-frame.ts @@ -51,7 +51,9 @@ function tripDatesOf(card: TripCard): string[] { /** The dated days of the trip inside `months`, as many as a card holds. */ export function tripDatesIn(card: TripCard, months: readonly CardMonth[]): string[] { - return tripDatesOf(card).filter((date) => inMonths(date, months)).slice(0, VACAY_CARD_MAX_DATES); + return tripDatesOf(card) + .filter((date) => inMonths(date, months)) + .slice(0, VACAY_CARD_MAX_DATES); } /** The company holidays inside `months`, each date once. */ diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts index 11a1f25fb..eae894f20 100644 --- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -130,7 +130,10 @@ export class RoadtripPlanningMcp { }); const betweenStops = (alongKm: number): [string, string] | null => { if (anchors.length < 2) return null; - const index = insertIndexForAlong(anchors.map((anchor) => anchor.alongKm), alongKm); + const index = insertIndexForAlong( + anchors.map((anchor) => anchor.alongKm), + alongKm, + ); return [anchors[index - 1].name, anchors[index].name]; }; const hits = new Map< diff --git a/server/tests/e2e/assistant.e2e.test.ts b/server/tests/e2e/assistant.e2e.test.ts index 4d0481cca..97152240d 100644 --- a/server/tests/e2e/assistant.e2e.test.ts +++ b/server/tests/e2e/assistant.e2e.test.ts @@ -1131,9 +1131,10 @@ describe('Trip assistant e2e', () => { const args = { question, options: ['Trattoria', 'Night market'] }; provider.script = [callTools({ id: 'call_poll', name: 'create_collab_poll', args })]; const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Ask the group' }); + const toolCallId = ofType(turn.chunks, 'tool-approval-request')[0]?.toolCallId ?? ''; const [change] = ofType(turn.chunks, 'data-trek-change'); expect(change.data).toMatchObject({ - toolCallId: 'call_poll', + toolCallId, verbKey: 'genericCreate', tool: 'create_collab_poll', domain: 'collab', @@ -1148,7 +1149,7 @@ describe('Trip assistant e2e', () => { provider.script = [say('The poll is up.')]; const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, { ...CTX, - decisions: [{ toolCallId: 'call_poll', approved: true }], + decisions: [{ toolCallId, approved: true }], }); expect(decided.status).toBe(200); const poll = await findRow(orm, CollabPolls, { trip: t.tripId }); @@ -1156,7 +1157,7 @@ describe('Trip assistant e2e', () => { const [applied] = ofType(decided.chunks, 'data-trek-applied'); expect(applied.data).toMatchObject({ entity: 'collab_poll', ids: [poll?.id] }); expect(ofType(decided.chunks, 'data-trek-card')[0].data).toMatchObject({ - toolCallId: 'call_poll', + toolCallId, card: { kind: 'collabPolls', polls: [{ pollId: poll?.id, change: 'create', question }] }, }); expect(broadcasts.broadcast).toHaveBeenCalledWith(t.tripId, 'collab:poll:created', expect.anything()); diff --git a/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts b/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts index c3ea82634..6559b4598 100644 --- a/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts +++ b/server/tests/unit/nest/assistant/cards/budget/budget-cards.test.ts @@ -222,7 +222,14 @@ describe('answer cards', () => { it('ASTCARD-BUD-004: an empty list, a broken answer and rows without a name or an amount show no card', () => { expect(answer('list_budget_items', { currency: 'EUR', total: 0, items: [] })).toBeNull(); expect(answer('list_budget_items', 'not a list')).toBeNull(); - expect(answer('list_budget_items', { items: [{ id: 1, total_price: 5 }, { id: 2, name: 'Tip' }] })).toBeNull(); + expect( + answer('list_budget_items', { + items: [ + { id: 1, total_price: 5 }, + { id: 2, name: 'Tip' }, + ], + }), + ).toBeNull(); expect(answer('get_settlement_summary', { error: 'nope' })).toBeNull(); expect(answer('get_budget_per_person', { summary: [] })).toBeNull(); expect(answer('list_settlements', { settlements: [] })).toBeNull(); diff --git a/server/tests/unit/nest/assistant/cards/collab/collab-cards.test.ts b/server/tests/unit/nest/assistant/cards/collab/collab-cards.test.ts index 51ccf10df..ad1d2dc8c 100644 --- a/server/tests/unit/nest/assistant/cards/collab/collab-cards.test.ts +++ b/server/tests/unit/nest/assistant/cards/collab/collab-cards.test.ts @@ -143,7 +143,14 @@ describe('the chat card of a read', () => { const row = message(4, { origin: 'assistant', reactions: [ - { emoji: '❤️', count: 2, users: [{ user_id: 7, username: 'anna' }, { user_id: 8, username: 'jon' }] }, + { + emoji: '❤️', + count: 2, + users: [ + { user_id: 7, username: 'anna' }, + { user_id: 8, username: 'jon' }, + ], + }, { emoji: '👍', count: 1, users: [{ user_id: 8, username: 'jon' }] }, ], attachments: [ diff --git a/server/tests/unit/nest/assistant/cards/collection/collection-changes.test.ts b/server/tests/unit/nest/assistant/cards/collection/collection-changes.test.ts index 2712632d7..bb08654f6 100644 --- a/server/tests/unit/nest/assistant/cards/collection/collection-changes.test.ts +++ b/server/tests/unit/nest/assistant/cards/collection/collection-changes.test.ts @@ -195,7 +195,12 @@ describe('collection change cards', () => { const deleted = preview('delete_collection', { collectionId: 12 }, { collection }); expect(deleted).toMatchObject({ subject: 'Paris food', verbKey: 'genericRemove' }); expect(listsOf(deleted.card).lists[0]).toMatchObject({ placeCount: 10, memberCount: 1, change: 'remove' }); - const lists = { collections: [{ id: 12, name: 'A' }, { id: 13, name: 'B' }] }; + const lists = { + collections: [ + { id: 12, name: 'A' }, + { id: 13, name: 'B' }, + ], + }; const order = preview('reorder_collections', { orderedIds: [13, 12] }, { lists }); expect(order.subject).toBe('B, A'); expect(listsOf(order.card).lists.map((row) => [row.name, row.change])).toEqual([ diff --git a/server/tests/unit/nest/assistant/cards/tour/tour-change.cards.test.ts b/server/tests/unit/nest/assistant/cards/tour/tour-change.cards.test.ts index d5e1f711f..e9a3f1f4e 100644 --- a/server/tests/unit/nest/assistant/cards/tour/tour-change.cards.test.ts +++ b/server/tests/unit/nest/assistant/cards/tour/tour-change.cards.test.ts @@ -342,7 +342,17 @@ describe('the words and the way back of the other tour writes', () => { expect(unchangedSinceApplied('create_hike', created, onItsDay)).toBe(true); const twice = { places: { places: [hike] }, - summary: { days: [{ id: 31, assignments: [{ id: 306, place_id: 64 }, { id: 305, place_id: 64 }] }] }, + summary: { + days: [ + { + id: 31, + assignments: [ + { id: 306, place_id: 64 }, + { id: 305, place_id: 64 }, + ], + }, + ], + }, }; expect(unchangedSinceApplied('create_hike', created, twice)).toBe(false); // A new way saved since (any route write stamps updated_at) keeps the tour, name and day unchanged or not. diff --git a/server/tests/unit/nest/assistant/cards/tour/tour-route.cards.test.ts b/server/tests/unit/nest/assistant/cards/tour/tour-route.cards.test.ts index 5aebe43c3..74e3e2683 100644 --- a/server/tests/unit/nest/assistant/cards/tour/tour-route.cards.test.ts +++ b/server/tests/unit/nest/assistant/cards/tour/tour-route.cards.test.ts @@ -13,13 +13,7 @@ import type { AnswerInput } from '../../../../../../src/nest/assistant/cards/car import { answerCardOf } from '../../../../../../src/nest/assistant/cards/registry'; import { fitted, hardestStretches } from '../../../../../../src/nest/assistant/cards/tour/tour-card-values'; import { sacStretches } from '../../../../../../src/nest/assistant/cards/tour/tour-route.cards'; -import { - MAX_CARD_CHARS, - trekCardSchema, - type TourRouteCard, - type TourRouteStretch, - type TrekCard, -} from '@trek/shared'; +import { MAX_CARD_CHARS, trekCardSchema, type TourRouteCard, type TourRouteStretch, type TrekCard } from '@trek/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/shared/src/assistant/cards/card-parts.ts b/shared/src/assistant/cards/card-parts.ts index fe9e74def..13d3b3d5c 100644 --- a/shared/src/assistant/cards/card-parts.ts +++ b/shared/src/assistant/cards/card-parts.ts @@ -17,8 +17,9 @@ export const cardLngSchema = z.number().min(-180).max(180); /** A point of a line to draw, `[lat, lng]` at five decimals; single points are `{ lat, lng }` objects. */ export type CardPoint = [number, number]; -// Typed by hand: server and client compile without strictNullChecks, where an inferred tuple makes every item optional. -export const cardPointSchema: z.ZodType = z.tuple([cardLatSchema, cardLngSchema]); +// Re-typed on the way out: server and client compile without strictNullChecks, where an inferred tuple +// makes every item optional and no longer fits a `[number, number]`. +export const cardPointSchema = z.tuple([cardLatSchema, cardLngSchema]).transform(([lat, lng]): CardPoint => [lat, lng]); export const cardDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); export const cardClockSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); diff --git a/shared/src/assistant/cards/tour-route.card.ts b/shared/src/assistant/cards/tour-route.card.ts index 913ee186f..08c9f20f5 100644 --- a/shared/src/assistant/cards/tour-route.card.ts +++ b/shared/src/assistant/cards/tour-route.card.ts @@ -38,12 +38,10 @@ const sacStep = z.number().int().min(1).max(6); /** A point of the line, `[lat, lng, height]` at five decimals; every height is null when one is missing. */ export type TourRoutePoint = [number, number, number | null]; -// Typed by hand like cardPointSchema: an inferred tuple reads every item as optional where strictNullChecks is off. -export const tourRoutePointSchema: z.ZodType = z.tuple([ - cardLatSchema, - cardLngSchema, - height.nullable(), -]); +// Re-typed on the way out like cardPointSchema, for the builds without strictNullChecks. +export const tourRoutePointSchema = z + .tuple([cardLatSchema, cardLngSchema, height.nullable()]) + .transform(([lat, lng, metres]): TourRoutePoint => [lat, lng, metres]); /** What a stop is beyond its role: a kind of place along a hike, its highest point, a city walk's station. */ export const TOUR_ROUTE_STOP_KINDS = ['highest', 'station'] as const;