diff --git a/src/index.ts b/src/index.ts index 3a3cb5f..8a3f4d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from './fare-contract'; export * from './offers/ticket-offer'; export * from './rules'; export * from './global-messages'; +export * from './transfer-risk'; export * from './common/app-platform'; export {ErrorResponse, HttpError} from './error-response'; export {BookingAvailabilityType} from './offers/booking'; diff --git a/src/transfer-risk/__tests__/transfer-risk.test.ts b/src/transfer-risk/__tests__/transfer-risk.test.ts new file mode 100644 index 0000000..4a525e0 --- /dev/null +++ b/src/transfer-risk/__tests__/transfer-risk.test.ts @@ -0,0 +1,246 @@ +import { + getTransferRisk, + getLegTransferRisk, + isTransitLeg, + UNLIKELY_TRANSFER_LIMIT_IN_SECONDS, + type TransferLeg, +} from '..'; + +const transitLeg = (overrides: Partial = {}): TransferLeg => ({ + aimedStartTime: '2024-01-01T10:00:00.000Z', + expectedStartTime: '2024-01-01T10:00:00.000Z', + expectedEndTime: '2024-01-01T10:10:00.000Z', + serviceJourney: {id: 'ATB:ServiceJourney:1'}, + ...overrides, +}); + +const footLeg = (overrides: Partial = {}): TransferLeg => ({ + aimedStartTime: '2024-01-01T10:10:00.000Z', + expectedStartTime: '2024-01-01T10:10:00.000Z', + expectedEndTime: '2024-01-01T10:15:00.000Z', + serviceJourney: null, + ...overrides, +}); + +describe('getTransferRisk', () => { + it('passes when there is time to spare', () => { + expect(getTransferRisk(1)).toBeUndefined(); + expect(getTransferRisk(600)).toBeUndefined(); + }); + + it('treats a zero gap as uncertain', () => { + expect(getTransferRisk(0)).toBe('uncertain'); + }); + + it('is uncertain down to the unlikely limit', () => { + expect(getTransferRisk(-60)).toBe('uncertain'); + expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS)).toBe( + 'uncertain', + ); + }); + + it('is unlikely past the limit', () => { + expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS - 1)).toBe( + 'unlikely', + ); + expect(getTransferRisk(-600)).toBe('unlikely'); + }); + + it('passes when the gap is not a finite number', () => { + expect(getTransferRisk(NaN)).toBeUndefined(); + }); +}); + +describe('isTransitLeg', () => { + it('distinguishes scheduled transit from walking', () => { + expect(isTransitLeg(transitLeg())).toBe(true); + expect(isTransitLeg(footLeg())).toBe(false); + expect(isTransitLeg(transitLeg({serviceJourney: undefined}))).toBe(false); + }); +}); + +describe('getLegTransferRisk', () => { + it('catches a missed transfer between two transit legs', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); + }); + + it('reports unlikely once the gap is past the limit', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:05:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBe('unlikely'); + }); + + it('passes when there is time to spare', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + it('passes on the first leg, which nothing precedes', () => { + expect(getLegTransferRisk([transitLeg(), transitLeg()], 0)).toBeUndefined(); + }); + + it('passes on an index outside the trip', () => { + expect(getLegTransferRisk([transitLeg()], 5)).toBeUndefined(); + }); + + it('passes on a non-transit leg, so a walk carries no warning', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + footLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + it('passes when there is no transit leg to have arrived from', () => { + const legs = [ + footLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + it('measures the gap from the end of an intervening walk', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + footLeg({ + expectedStartTime: '2024-01-01T10:10:00.000Z', + expectedEndTime: '2024-01-01T10:15:00.000Z', + }), + transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 2)).toBe('unlikely'); + }); + + it('passes when the gap is unparseable rather than inventing a risk', () => { + const legs = [ + transitLeg({expectedEndTime: 'not-a-date'}), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + describe('guaranteed interchange', () => { + it('passes on a guaranteed interchange with no stated wait limit', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + it('warns when the interchange is explicitly not guaranteed', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: false}, + }), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); + }); + + it('passes on a guaranteed interchange reached through a walk', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + footLeg({ + expectedStartTime: '2024-01-01T10:10:00.000Z', + expectedEndTime: '2024-01-01T10:15:00.000Z', + }), + transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}), + ]; + expect(getLegTransferRisk(legs, 2)).toBeUndefined(); + }); + + it('passes when arrival is within the maximum wait time', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + transitLeg({ + aimedStartTime: '2024-01-01T10:08:00.000Z', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + // Held until 10:08 + 5 min = 10:13, and we arrive at 10:10. + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + it('treats arrival exactly at the deadline as caught', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:13:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + transitLeg({ + aimedStartTime: '2024-01-01T10:08:00.000Z', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + + it('warns once arrival is past the maximum wait time', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:20:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + transitLeg({ + aimedStartTime: '2024-01-01T10:08:00.000Z', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + // Held until 10:13, but we do not arrive until 10:20. + expect(getLegTransferRisk(legs, 1)).toBe('unlikely'); + }); + + it('counts an intervening walk against the maximum wait time', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 120}, + }), + footLeg({ + expectedStartTime: '2024-01-01T10:10:00.000Z', + expectedEndTime: '2024-01-01T10:15:00.000Z', + }), + transitLeg({ + aimedStartTime: '2024-01-01T10:11:00.000Z', + expectedStartTime: '2024-01-01T10:11:00.000Z', + }), + ]; + // Held until 10:13, but the walk does not end until 10:15. + expect(getLegTransferRisk(legs, 2)).toBe('unlikely'); + }); + + it('keeps the guarantee when the deadline is unparseable', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:20:00.000Z', + interchangeTo: {guaranteed: true, maximumWaitTime: 300}, + }), + transitLeg({ + aimedStartTime: 'not-a-date', + expectedStartTime: '2024-01-01T10:08:00.000Z', + }), + ]; + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); + }); + }); +}); diff --git a/src/transfer-risk/index.ts b/src/transfer-risk/index.ts new file mode 100644 index 0000000..9508c1b --- /dev/null +++ b/src/transfer-risk/index.ts @@ -0,0 +1,9 @@ +export { + getTransferRisk, + getLegTransferRisk, + isTransitLeg, + UNLIKELY_TRANSFER_LIMIT_IN_SECONDS, +} from './transfer-risk'; +// Exports both the value (TransferRisk.Unlikely) and the type. +export {TransferRisk} from './types'; +export type {TransferLeg} from './types'; diff --git a/src/transfer-risk/transfer-risk.ts b/src/transfer-risk/transfer-risk.ts new file mode 100644 index 0000000..0268eb5 --- /dev/null +++ b/src/transfer-risk/transfer-risk.ts @@ -0,0 +1,92 @@ +import type {TransferLeg} from './types'; +import {TransferRisk} from './types'; + +/** Below this, the transfer is not one to count on. */ +export const UNLIKELY_TRANSFER_LIMIT_IN_SECONDS = -120; + +/** + * Classifies the gap between arriving and the next departure. Zero counts as + * uncertain; a non-finite gap yields undefined. + */ +export const getTransferRisk = (seconds: number): TransferRisk | undefined => { + if (!Number.isFinite(seconds) || seconds > 0) { + return undefined; + } + return seconds < UNLIKELY_TRANSFER_LIMIT_IN_SECONDS + ? TransferRisk.Unlikely + : TransferRisk.Uncertain; +}; + +/** Whether a leg is scheduled transit rather than walking, cycling and such. */ +export const isTransitLeg = (leg: TransferLeg): boolean => + leg.serviceJourney != null; + +/** + * The risk of missing the leg at `index` — the service you are boarding, which + * is where the warning belongs. Arrival is the end of the leg immediately + * before, so an intervening walk counts. + * + * Undefined when the leg is not transit, when no transit leg precedes it, or + * when the transfer still holds. The transit check also keeps the warning off + * the leg leading *into* a walk: those gaps are commonly re-anchored to exactly + * zero, which would otherwise fire on every transfer. + */ +export const getLegTransferRisk = ( + legs: TransferLeg[], + index: number, +): TransferRisk | undefined => { + const boarding = legs[index]; + const arriveAt = legs[index - 1]; + if (!boarding || !arriveAt || !isTransitLeg(boarding)) return undefined; + + const alightedFrom = previousTransitLeg(legs, index); + if (!alightedFrom) return undefined; + if (transferHolds(alightedFrom, boarding, arriveAt)) return undefined; + + return getTransferRisk( + secondsBetween(arriveAt.expectedEndTime, boarding.expectedStartTime), + ); +}; + +/** + * The transit leg you alight from, which carries the interchange. Walks back + * past non-transit legs: bus -> walk -> bus is measured on the (walk, bus) + * pair, but the first bus holds `interchangeTo`. + */ +const previousTransitLeg = ( + legs: TransferLeg[], + index: number, +): TransferLeg | undefined => { + for (let i = index - 1; i >= 0; i--) { + if (isTransitLeg(legs[i])) return legs[i]; + } + return undefined; +}; + +/** + * Whether the interchange still guarantees the transfer. A guarantee lasts + * `maximumWaitTime` seconds past the connecting service's scheduled departure; + * absent, it waits indefinitely. Unparseable times keep the guarantee, so bad + * data suppresses a warning rather than inventing one. + */ +const transferHolds = ( + alightedFrom: TransferLeg, + boarding: TransferLeg, + arriveAt: TransferLeg, +): boolean => { + const interchange = alightedFrom.interchangeTo; + if (interchange?.guaranteed !== true) return false; + if (interchange.maximumWaitTime == null) return true; + + const deadline = + toEpochMs(boarding.aimedStartTime) + interchange.maximumWaitTime * 1000; + const arrival = toEpochMs(arriveAt.expectedEndTime); + if (!Number.isFinite(deadline) || !Number.isFinite(arrival)) return true; + + return arrival <= deadline; +}; + +const secondsBetween = (from: string, to: string): number => + (toEpochMs(to) - toEpochMs(from)) / 1000; + +const toEpochMs = (isoDate: string): number => new Date(isoDate).getTime(); diff --git a/src/transfer-risk/types.ts b/src/transfer-risk/types.ts new file mode 100644 index 0000000..a1abe9d --- /dev/null +++ b/src/transfer-risk/types.ts @@ -0,0 +1,32 @@ +/** How risky a transfer is when there is no time to spare. */ +export const TransferRisk = { + Uncertain: 'uncertain', + Unlikely: 'unlikely', +} as const; + +export type TransferRisk = (typeof TransferRisk)[keyof typeof TransferRisk]; + +/** + * The fields the transfer rules read. Each product's own leg type satisfies + * this structurally, so no mapping is needed at the call site. + */ +export type TransferLeg = { + /** Scheduled departure. The reference point for `maximumWaitTime`. */ + aimedStartTime: string; + expectedStartTime: string; + expectedEndTime: string; + /** + * Present on scheduled transit legs only. More reliable than mode or quay: + * a transfer walk between two stops has quays too. + */ + serviceJourney?: {id: string} | null; + /** + * Entur's interchange to the next service. Transit legs only, and often not + * populated when a leg is fetched by id — preserve it across a refresh. + */ + interchangeTo?: { + guaranteed?: boolean | null; + /** Seconds past its own scheduled departure the connecting service waits. */ + maximumWaitTime?: number | null; + } | null; +};