From e453f1e3c8ee3e465be0aa0acb8e42ae74b83e97 Mon Sep 17 00:00:00 2001 From: Ridwan Aditama Date: Thu, 3 Sep 2026 10:42:28 +0200 Subject: [PATCH 1/2] feat: add interchange risk logic --- src/index.ts | 1 + .../__tests__/interchange-risk.test.ts | 250 ++++++++++++++++++ src/interchange-risk/index.ts | 9 + src/interchange-risk/interchange-risk.ts | 94 +++++++ src/interchange-risk/types.ts | 33 +++ 5 files changed, 387 insertions(+) create mode 100644 src/interchange-risk/__tests__/interchange-risk.test.ts create mode 100644 src/interchange-risk/index.ts create mode 100644 src/interchange-risk/interchange-risk.ts create mode 100644 src/interchange-risk/types.ts diff --git a/src/index.ts b/src/index.ts index 3a3cb5f..58ee66e 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 './interchange-risk'; export * from './common/app-platform'; export {ErrorResponse, HttpError} from './error-response'; export {BookingAvailabilityType} from './offers/booking'; diff --git a/src/interchange-risk/__tests__/interchange-risk.test.ts b/src/interchange-risk/__tests__/interchange-risk.test.ts new file mode 100644 index 0000000..8a21320 --- /dev/null +++ b/src/interchange-risk/__tests__/interchange-risk.test.ts @@ -0,0 +1,250 @@ +import { + getInterchangeRisk, + getLegInterchangeRisk, + isTransitLeg, + UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS, + type InterchangeLeg, +} from '..'; + +const transitLeg = ( + overrides: Partial = {}, +): InterchangeLeg => ({ + 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 = {}): InterchangeLeg => ({ + 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('getInterchangeRisk', () => { + it('passes when there is time to spare', () => { + expect(getInterchangeRisk(1)).toBeUndefined(); + expect(getInterchangeRisk(600)).toBeUndefined(); + }); + + it('treats a zero gap as uncertain', () => { + expect(getInterchangeRisk(0)).toBe('uncertain'); + }); + + it('is uncertain down to the unlikely limit', () => { + expect(getInterchangeRisk(-60)).toBe('uncertain'); + expect(getInterchangeRisk(UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS)).toBe( + 'uncertain', + ); + }); + + it('is unlikely past the limit', () => { + expect(getInterchangeRisk(UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS - 1)).toBe( + 'unlikely', + ); + expect(getInterchangeRisk(-600)).toBe('unlikely'); + }); + + it('passes when the gap is not a finite number', () => { + expect(getInterchangeRisk(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('getLegInterchangeRisk', () => { + it('catches a missed interchange between two transit legs', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + }); + + it('passes on the first leg, which nothing precedes', () => { + expect( + getLegInterchangeRisk([transitLeg(), transitLeg()], 0), + ).toBeUndefined(); + }); + + it('passes on an index outside the trip', () => { + expect(getLegInterchangeRisk([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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(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(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + }); + }); +}); diff --git a/src/interchange-risk/index.ts b/src/interchange-risk/index.ts new file mode 100644 index 0000000..39d368d --- /dev/null +++ b/src/interchange-risk/index.ts @@ -0,0 +1,9 @@ +export { + getInterchangeRisk, + getLegInterchangeRisk, + isTransitLeg, + UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS, +} from './interchange-risk'; +// Exports both the value (InterchangeRisk.Unlikely) and the type. +export {InterchangeRisk} from './types'; +export type {InterchangeLeg} from './types'; diff --git a/src/interchange-risk/interchange-risk.ts b/src/interchange-risk/interchange-risk.ts new file mode 100644 index 0000000..c934f6b --- /dev/null +++ b/src/interchange-risk/interchange-risk.ts @@ -0,0 +1,94 @@ +import type {InterchangeLeg} from './types'; +import {InterchangeRisk} from './types'; + +/** Below this, the interchange is not one to count on. */ +export const UNLIKELY_INTERCHANGE_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 getInterchangeRisk = ( + seconds: number, +): InterchangeRisk | undefined => { + if (!Number.isFinite(seconds) || seconds > 0) { + return undefined; + } + return seconds < UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS + ? InterchangeRisk.Unlikely + : InterchangeRisk.Uncertain; +}; + +/** Whether a leg is scheduled transit rather than walking, cycling and such. */ +export const isTransitLeg = (leg: InterchangeLeg): 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 interchange 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 getLegInterchangeRisk = ( + legs: InterchangeLeg[], + index: number, +): InterchangeRisk | 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 (interchangeHolds(alightedFrom, boarding, arriveAt)) return undefined; + + return getInterchangeRisk( + 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: InterchangeLeg[], + index: number, +): InterchangeLeg | undefined => { + for (let i = index - 1; i >= 0; i--) { + if (isTransitLeg(legs[i])) return legs[i]; + } + return undefined; +}; + +/** + * Whether the interchange still guarantees the connection. 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 interchangeHolds = ( + alightedFrom: InterchangeLeg, + boarding: InterchangeLeg, + arriveAt: InterchangeLeg, +): 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/interchange-risk/types.ts b/src/interchange-risk/types.ts new file mode 100644 index 0000000..17d259c --- /dev/null +++ b/src/interchange-risk/types.ts @@ -0,0 +1,33 @@ +/** How risky an interchange is when there is no time to spare. */ +export const InterchangeRisk = { + Uncertain: 'uncertain', + Unlikely: 'unlikely', +} as const; + +export type InterchangeRisk = + (typeof InterchangeRisk)[keyof typeof InterchangeRisk]; + +/** + * The fields the interchange rules read. Each product's own leg type satisfies + * this structurally, so no mapping is needed at the call site. + */ +export type InterchangeLeg = { + /** 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; + /** + * 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; +}; From 35fe02b410945f17cedd5a8312787e50475b123d Mon Sep 17 00:00:00 2001 From: Ridwan Aditama Date: Thu, 3 Sep 2026 13:37:00 +0200 Subject: [PATCH 2/2] refactor: rename interchange to transfer --- src/index.ts | 2 +- src/interchange-risk/index.ts | 9 --- .../__tests__/transfer-risk.test.ts} | 72 +++++++++---------- src/transfer-risk/index.ts | 9 +++ .../transfer-risk.ts} | 50 +++++++------ .../types.ts | 13 ++-- 6 files changed, 74 insertions(+), 81 deletions(-) delete mode 100644 src/interchange-risk/index.ts rename src/{interchange-risk/__tests__/interchange-risk.test.ts => transfer-risk/__tests__/transfer-risk.test.ts} (76%) create mode 100644 src/transfer-risk/index.ts rename src/{interchange-risk/interchange-risk.ts => transfer-risk/transfer-risk.ts} (65%) rename src/{interchange-risk => transfer-risk}/types.ts (66%) diff --git a/src/index.ts b/src/index.ts index 58ee66e..8a3f4d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export * from './fare-contract'; export * from './offers/ticket-offer'; export * from './rules'; export * from './global-messages'; -export * from './interchange-risk'; +export * from './transfer-risk'; export * from './common/app-platform'; export {ErrorResponse, HttpError} from './error-response'; export {BookingAvailabilityType} from './offers/booking'; diff --git a/src/interchange-risk/index.ts b/src/interchange-risk/index.ts deleted file mode 100644 index 39d368d..0000000 --- a/src/interchange-risk/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - getInterchangeRisk, - getLegInterchangeRisk, - isTransitLeg, - UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS, -} from './interchange-risk'; -// Exports both the value (InterchangeRisk.Unlikely) and the type. -export {InterchangeRisk} from './types'; -export type {InterchangeLeg} from './types'; diff --git a/src/interchange-risk/__tests__/interchange-risk.test.ts b/src/transfer-risk/__tests__/transfer-risk.test.ts similarity index 76% rename from src/interchange-risk/__tests__/interchange-risk.test.ts rename to src/transfer-risk/__tests__/transfer-risk.test.ts index 8a21320..4a525e0 100644 --- a/src/interchange-risk/__tests__/interchange-risk.test.ts +++ b/src/transfer-risk/__tests__/transfer-risk.test.ts @@ -1,14 +1,12 @@ import { - getInterchangeRisk, - getLegInterchangeRisk, + getTransferRisk, + getLegTransferRisk, isTransitLeg, - UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS, - type InterchangeLeg, + UNLIKELY_TRANSFER_LIMIT_IN_SECONDS, + type TransferLeg, } from '..'; -const transitLeg = ( - overrides: Partial = {}, -): InterchangeLeg => ({ +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', @@ -16,7 +14,7 @@ const transitLeg = ( ...overrides, }); -const footLeg = (overrides: Partial = {}): InterchangeLeg => ({ +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', @@ -24,32 +22,32 @@ const footLeg = (overrides: Partial = {}): InterchangeLeg => ({ ...overrides, }); -describe('getInterchangeRisk', () => { +describe('getTransferRisk', () => { it('passes when there is time to spare', () => { - expect(getInterchangeRisk(1)).toBeUndefined(); - expect(getInterchangeRisk(600)).toBeUndefined(); + expect(getTransferRisk(1)).toBeUndefined(); + expect(getTransferRisk(600)).toBeUndefined(); }); it('treats a zero gap as uncertain', () => { - expect(getInterchangeRisk(0)).toBe('uncertain'); + expect(getTransferRisk(0)).toBe('uncertain'); }); it('is uncertain down to the unlikely limit', () => { - expect(getInterchangeRisk(-60)).toBe('uncertain'); - expect(getInterchangeRisk(UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS)).toBe( + expect(getTransferRisk(-60)).toBe('uncertain'); + expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS)).toBe( 'uncertain', ); }); it('is unlikely past the limit', () => { - expect(getInterchangeRisk(UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS - 1)).toBe( + expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS - 1)).toBe( 'unlikely', ); - expect(getInterchangeRisk(-600)).toBe('unlikely'); + expect(getTransferRisk(-600)).toBe('unlikely'); }); it('passes when the gap is not a finite number', () => { - expect(getInterchangeRisk(NaN)).toBeUndefined(); + expect(getTransferRisk(NaN)).toBeUndefined(); }); }); @@ -61,13 +59,13 @@ describe('isTransitLeg', () => { }); }); -describe('getLegInterchangeRisk', () => { - it('catches a missed interchange between two transit legs', () => { +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(getLegInterchangeRisk(legs, 1)).toBe('uncertain'); + expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); }); it('reports unlikely once the gap is past the limit', () => { @@ -75,7 +73,7 @@ describe('getLegInterchangeRisk', () => { transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), transitLeg({expectedStartTime: '2024-01-01T10:05:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 1)).toBe('unlikely'); }); it('passes when there is time to spare', () => { @@ -83,17 +81,15 @@ describe('getLegInterchangeRisk', () => { transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); it('passes on the first leg, which nothing precedes', () => { - expect( - getLegInterchangeRisk([transitLeg(), transitLeg()], 0), - ).toBeUndefined(); + expect(getLegTransferRisk([transitLeg(), transitLeg()], 0)).toBeUndefined(); }); it('passes on an index outside the trip', () => { - expect(getLegInterchangeRisk([transitLeg()], 5)).toBeUndefined(); + expect(getLegTransferRisk([transitLeg()], 5)).toBeUndefined(); }); it('passes on a non-transit leg, so a walk carries no warning', () => { @@ -101,7 +97,7 @@ describe('getLegInterchangeRisk', () => { transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), footLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); it('passes when there is no transit leg to have arrived from', () => { @@ -109,7 +105,7 @@ describe('getLegInterchangeRisk', () => { footLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); it('measures the gap from the end of an intervening walk', () => { @@ -121,7 +117,7 @@ describe('getLegInterchangeRisk', () => { }), transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 2)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 2)).toBe('unlikely'); }); it('passes when the gap is unparseable rather than inventing a risk', () => { @@ -129,7 +125,7 @@ describe('getLegInterchangeRisk', () => { transitLeg({expectedEndTime: 'not-a-date'}), transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); describe('guaranteed interchange', () => { @@ -141,7 +137,7 @@ describe('getLegInterchangeRisk', () => { }), transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); it('warns when the interchange is explicitly not guaranteed', () => { @@ -152,7 +148,7 @@ describe('getLegInterchangeRisk', () => { }), transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 1)).toBe('uncertain'); + expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); }); it('passes on a guaranteed interchange reached through a walk', () => { @@ -167,7 +163,7 @@ describe('getLegInterchangeRisk', () => { }), transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}), ]; - expect(getLegInterchangeRisk(legs, 2)).toBeUndefined(); + expect(getLegTransferRisk(legs, 2)).toBeUndefined(); }); it('passes when arrival is within the maximum wait time', () => { @@ -182,7 +178,7 @@ describe('getLegInterchangeRisk', () => { }), ]; // Held until 10:08 + 5 min = 10:13, and we arrive at 10:10. - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); it('treats arrival exactly at the deadline as caught', () => { @@ -196,7 +192,7 @@ describe('getLegInterchangeRisk', () => { expectedStartTime: '2024-01-01T10:08:00.000Z', }), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + expect(getLegTransferRisk(legs, 1)).toBeUndefined(); }); it('warns once arrival is past the maximum wait time', () => { @@ -211,7 +207,7 @@ describe('getLegInterchangeRisk', () => { }), ]; // Held until 10:13, but we do not arrive until 10:20. - expect(getLegInterchangeRisk(legs, 1)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 1)).toBe('unlikely'); }); it('counts an intervening walk against the maximum wait time', () => { @@ -230,7 +226,7 @@ describe('getLegInterchangeRisk', () => { }), ]; // Held until 10:13, but the walk does not end until 10:15. - expect(getLegInterchangeRisk(legs, 2)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 2)).toBe('unlikely'); }); it('keeps the guarantee when the deadline is unparseable', () => { @@ -244,7 +240,7 @@ describe('getLegInterchangeRisk', () => { expectedStartTime: '2024-01-01T10:08:00.000Z', }), ]; - expect(getLegInterchangeRisk(legs, 1)).toBeUndefined(); + 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/interchange-risk/interchange-risk.ts b/src/transfer-risk/transfer-risk.ts similarity index 65% rename from src/interchange-risk/interchange-risk.ts rename to src/transfer-risk/transfer-risk.ts index c934f6b..0268eb5 100644 --- a/src/interchange-risk/interchange-risk.ts +++ b/src/transfer-risk/transfer-risk.ts @@ -1,26 +1,24 @@ -import type {InterchangeLeg} from './types'; -import {InterchangeRisk} from './types'; +import type {TransferLeg} from './types'; +import {TransferRisk} from './types'; -/** Below this, the interchange is not one to count on. */ -export const UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS = -120; +/** 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 getInterchangeRisk = ( - seconds: number, -): InterchangeRisk | undefined => { +export const getTransferRisk = (seconds: number): TransferRisk | undefined => { if (!Number.isFinite(seconds) || seconds > 0) { return undefined; } - return seconds < UNLIKELY_INTERCHANGE_LIMIT_IN_SECONDS - ? InterchangeRisk.Unlikely - : InterchangeRisk.Uncertain; + 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: InterchangeLeg): boolean => +export const isTransitLeg = (leg: TransferLeg): boolean => leg.serviceJourney != null; /** @@ -29,23 +27,23 @@ export const isTransitLeg = (leg: InterchangeLeg): boolean => * before, so an intervening walk counts. * * Undefined when the leg is not transit, when no transit leg precedes it, or - * when the interchange 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. + * 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 getLegInterchangeRisk = ( - legs: InterchangeLeg[], +export const getLegTransferRisk = ( + legs: TransferLeg[], index: number, -): InterchangeRisk | undefined => { +): 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 (interchangeHolds(alightedFrom, boarding, arriveAt)) return undefined; + if (transferHolds(alightedFrom, boarding, arriveAt)) return undefined; - return getInterchangeRisk( + return getTransferRisk( secondsBetween(arriveAt.expectedEndTime, boarding.expectedStartTime), ); }; @@ -56,9 +54,9 @@ export const getLegInterchangeRisk = ( * pair, but the first bus holds `interchangeTo`. */ const previousTransitLeg = ( - legs: InterchangeLeg[], + legs: TransferLeg[], index: number, -): InterchangeLeg | undefined => { +): TransferLeg | undefined => { for (let i = index - 1; i >= 0; i--) { if (isTransitLeg(legs[i])) return legs[i]; } @@ -66,15 +64,15 @@ const previousTransitLeg = ( }; /** - * Whether the interchange still guarantees the connection. A guarantee lasts + * 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 interchangeHolds = ( - alightedFrom: InterchangeLeg, - boarding: InterchangeLeg, - arriveAt: InterchangeLeg, +const transferHolds = ( + alightedFrom: TransferLeg, + boarding: TransferLeg, + arriveAt: TransferLeg, ): boolean => { const interchange = alightedFrom.interchangeTo; if (interchange?.guaranteed !== true) return false; diff --git a/src/interchange-risk/types.ts b/src/transfer-risk/types.ts similarity index 66% rename from src/interchange-risk/types.ts rename to src/transfer-risk/types.ts index 17d259c..a1abe9d 100644 --- a/src/interchange-risk/types.ts +++ b/src/transfer-risk/types.ts @@ -1,17 +1,16 @@ -/** How risky an interchange is when there is no time to spare. */ -export const InterchangeRisk = { +/** How risky a transfer is when there is no time to spare. */ +export const TransferRisk = { Uncertain: 'uncertain', Unlikely: 'unlikely', } as const; -export type InterchangeRisk = - (typeof InterchangeRisk)[keyof typeof InterchangeRisk]; +export type TransferRisk = (typeof TransferRisk)[keyof typeof TransferRisk]; /** - * The fields the interchange rules read. Each product's own leg type satisfies + * 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 InterchangeLeg = { +export type TransferLeg = { /** Scheduled departure. The reference point for `maximumWaitTime`. */ aimedStartTime: string; expectedStartTime: string; @@ -22,7 +21,7 @@ export type InterchangeLeg = { */ serviceJourney?: {id: string} | null; /** - * Interchange to the next service. Transit legs only, and often not + * 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?: {