Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
246 changes: 246 additions & 0 deletions src/transfer-risk/__tests__/transfer-risk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import {
getTransferRisk,
getLegTransferRisk,
isTransitLeg,
UNLIKELY_TRANSFER_LIMIT_IN_SECONDS,
type TransferLeg,
} from '..';

const transitLeg = (overrides: Partial<TransferLeg> = {}): 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> = {}): 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();
});
});
});
9 changes: 9 additions & 0 deletions src/transfer-risk/index.ts
Original file line number Diff line number Diff line change
@@ -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';
92 changes: 92 additions & 0 deletions src/transfer-risk/transfer-risk.ts
Original file line number Diff line number Diff line change
@@ -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();
32 changes: 32 additions & 0 deletions src/transfer-risk/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading