From f6b79989d087464239c5268b270b9b9f5bf13e4f Mon Sep 17 00:00:00 2001 From: Ridwan Aditama Date: Fri, 4 Sep 2026 10:43:01 +0200 Subject: [PATCH 1/3] feat!: only use `uncertain` transfer risk --- .../__tests__/transfer-risk.test.ts | 25 ++++++------------- src/transfer-risk/index.ts | 3 +-- src/transfer-risk/transfer-risk.ts | 13 ++++------ src/transfer-risk/types.ts | 6 +++-- 4 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/transfer-risk/__tests__/transfer-risk.test.ts b/src/transfer-risk/__tests__/transfer-risk.test.ts index 4a525e0..ca8c811 100644 --- a/src/transfer-risk/__tests__/transfer-risk.test.ts +++ b/src/transfer-risk/__tests__/transfer-risk.test.ts @@ -2,7 +2,6 @@ import { getTransferRisk, getLegTransferRisk, isTransitLeg, - UNLIKELY_TRANSFER_LIMIT_IN_SECONDS, type TransferLeg, } from '..'; @@ -32,18 +31,10 @@ describe('getTransferRisk', () => { expect(getTransferRisk(0)).toBe('uncertain'); }); - it('is uncertain down to the unlikely limit', () => { + it('is uncertain at any negative gap, however large', () => { + expect(getTransferRisk(-1)).toBe('uncertain'); 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'); + expect(getTransferRisk(-600)).toBe('uncertain'); }); it('passes when the gap is not a finite number', () => { @@ -68,12 +59,12 @@ describe('getLegTransferRisk', () => { expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); }); - it('reports unlikely once the gap is past the limit', () => { + it('stays uncertain on a badly missed transfer', () => { const legs = [ transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), transitLeg({expectedStartTime: '2024-01-01T10:05:00.000Z'}), ]; - expect(getLegTransferRisk(legs, 1)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); }); it('passes when there is time to spare', () => { @@ -117,7 +108,7 @@ describe('getLegTransferRisk', () => { }), transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}), ]; - expect(getLegTransferRisk(legs, 2)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 2)).toBe('uncertain'); }); it('passes when the gap is unparseable rather than inventing a risk', () => { @@ -207,7 +198,7 @@ describe('getLegTransferRisk', () => { }), ]; // Held until 10:13, but we do not arrive until 10:20. - expect(getLegTransferRisk(legs, 1)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 1)).toBe('uncertain'); }); it('counts an intervening walk against the maximum wait time', () => { @@ -226,7 +217,7 @@ describe('getLegTransferRisk', () => { }), ]; // Held until 10:13, but the walk does not end until 10:15. - expect(getLegTransferRisk(legs, 2)).toBe('unlikely'); + expect(getLegTransferRisk(legs, 2)).toBe('uncertain'); }); it('keeps the guarantee when the deadline is unparseable', () => { diff --git a/src/transfer-risk/index.ts b/src/transfer-risk/index.ts index 9508c1b..0f27f5f 100644 --- a/src/transfer-risk/index.ts +++ b/src/transfer-risk/index.ts @@ -2,8 +2,7 @@ export { getTransferRisk, getLegTransferRisk, isTransitLeg, - UNLIKELY_TRANSFER_LIMIT_IN_SECONDS, } from './transfer-risk'; -// Exports both the value (TransferRisk.Unlikely) and the type. +// Exports both the value (TransferRisk.Uncertain) 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 index 0268eb5..254f47b 100644 --- a/src/transfer-risk/transfer-risk.ts +++ b/src/transfer-risk/transfer-risk.ts @@ -1,20 +1,17 @@ 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. + * Classifies the gap between arriving and the next departure: any gap that is + * not positive is uncertain, however large. Zero counts, because arriving + * exactly as the service leaves is not a transfer you can rely on. 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; + return TransferRisk.Uncertain; }; /** Whether a leg is scheduled transit rather than walking, cycling and such. */ diff --git a/src/transfer-risk/types.ts b/src/transfer-risk/types.ts index a1abe9d..566c83f 100644 --- a/src/transfer-risk/types.ts +++ b/src/transfer-risk/types.ts @@ -1,7 +1,9 @@ -/** How risky a transfer is when there is no time to spare. */ +/** + * A transfer with no time to spare. One level today; kept as a string rather + * than a boolean because `shortWait` will be added soon. + */ export const TransferRisk = { Uncertain: 'uncertain', - Unlikely: 'unlikely', } as const; export type TransferRisk = (typeof TransferRisk)[keyof typeof TransferRisk]; From 7dfc4883e896357b9feae91aaf6c64d318933398 Mon Sep 17 00:00:00 2001 From: Ridwan Aditama Date: Fri, 4 Sep 2026 11:29:25 +0200 Subject: [PATCH 2/3] refactor: update wording Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/transfer-risk/types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/transfer-risk/types.ts b/src/transfer-risk/types.ts index 566c83f..e191047 100644 --- a/src/transfer-risk/types.ts +++ b/src/transfer-risk/types.ts @@ -1,6 +1,7 @@ /** - * A transfer with no time to spare. One level today; kept as a string rather - * than a boolean because `shortWait` will be added soon. + * Transfer risk classification for transfers with no slack (0s) or already + * missed (negative gaps). Kept as a string (vs boolean) because additional + * levels (e.g. `shortWait`) may be added later. */ export const TransferRisk = { Uncertain: 'uncertain', From 9f0679b200db516e13fc25fcc9386354c86574a0 Mon Sep 17 00:00:00 2001 From: Ridwan Aditama Date: Fri, 4 Sep 2026 13:19:16 +0200 Subject: [PATCH 3/3] feat: share transfer risk stamping and trip aggregation --- .../__tests__/transfer-risk.test.ts | 94 ++++++++++++++++++- src/transfer-risk/index.ts | 2 + src/transfer-risk/transfer-risk.ts | 40 ++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/transfer-risk/__tests__/transfer-risk.test.ts b/src/transfer-risk/__tests__/transfer-risk.test.ts index ca8c811..4134358 100644 --- a/src/transfer-risk/__tests__/transfer-risk.test.ts +++ b/src/transfer-risk/__tests__/transfer-risk.test.ts @@ -1,11 +1,17 @@ import { getTransferRisk, getLegTransferRisk, + getTripTransferRisk, + withTransferRisk, isTransitLeg, + TransferRisk, type TransferLeg, } from '..'; -const transitLeg = (overrides: Partial = {}): TransferLeg => ({ +/** A leg that can carry a stamped risk, as every real consumer's leg can. */ +type TestLeg = TransferLeg & {transferRisk?: TransferRisk}; + +const transitLeg = (overrides: Partial = {}): TestLeg => ({ aimedStartTime: '2024-01-01T10:00:00.000Z', expectedStartTime: '2024-01-01T10:00:00.000Z', expectedEndTime: '2024-01-01T10:10:00.000Z', @@ -13,7 +19,7 @@ const transitLeg = (overrides: Partial = {}): TransferLeg => ({ ...overrides, }); -const footLeg = (overrides: Partial = {}): TransferLeg => ({ +const footLeg = (overrides: Partial = {}): TestLeg => ({ aimedStartTime: '2024-01-01T10:10:00.000Z', expectedStartTime: '2024-01-01T10:10:00.000Z', expectedEndTime: '2024-01-01T10:15:00.000Z', @@ -235,3 +241,87 @@ describe('getLegTransferRisk', () => { }); }); }); + +describe('withTransferRisk', () => { + it('stamps the leg you might miss, not the one before', () => { + const legs = withTransferRisk([ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]); + expect(legs[0].transferRisk).toBeUndefined(); + expect(legs[1].transferRisk).toBe('uncertain'); + }); + + it('leaves a comfortable transfer unstamped', () => { + const legs = withTransferRisk([ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}), + ]); + expect(legs.every((leg) => leg.transferRisk === undefined)).toBe(true); + }); + + it('clears a risk the caller passed back in, once the gap is fine', () => { + const legs = withTransferRisk([ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({ + expectedStartTime: '2024-01-01T10:15:00.000Z', + transferRisk: TransferRisk.Uncertain, + }), + ]); + expect(legs[1].transferRisk).toBeUndefined(); + }); + + it('does not stamp a guaranteed transfer', () => { + const legs = withTransferRisk([ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}), + ]); + expect(legs[1].transferRisk).toBeUndefined(); + }); +}); + +describe('getTripTransferRisk', () => { + it('passes when every transfer has time to spare', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}), + ]; + expect(getTripTransferRisk(legs)).toBeUndefined(); + }); + + it('reports a risk from anywhere in the trip', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({ + expectedStartTime: '2024-01-01T10:15:00.000Z', + expectedEndTime: '2024-01-01T10:25:00.000Z', + }), + transitLeg({expectedStartTime: '2024-01-01T10:24:00.000Z'}), + ]; + expect(getTripTransferRisk(legs)).toBe('uncertain'); + }); + + it('ignores a guaranteed transfer when looking across the trip', () => { + const legs = [ + transitLeg({ + expectedEndTime: '2024-01-01T10:10:00.000Z', + interchangeTo: {guaranteed: true}, + }), + transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}), + ]; + expect(getTripTransferRisk(legs)).toBeUndefined(); + }); + + it('does not need the legs to be stamped first', () => { + const legs = [ + transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}), + transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}), + ]; + expect(getTripTransferRisk(legs)).toBe( + getTripTransferRisk(withTransferRisk(legs)), + ); + }); +}); diff --git a/src/transfer-risk/index.ts b/src/transfer-risk/index.ts index 0f27f5f..7c4d5db 100644 --- a/src/transfer-risk/index.ts +++ b/src/transfer-risk/index.ts @@ -1,6 +1,8 @@ export { getTransferRisk, getLegTransferRisk, + getTripTransferRisk, + withTransferRisk, isTransitLeg, } from './transfer-risk'; // Exports both the value (TransferRisk.Uncertain) and the type. diff --git a/src/transfer-risk/transfer-risk.ts b/src/transfer-risk/transfer-risk.ts index 254f47b..e073458 100644 --- a/src/transfer-risk/transfer-risk.ts +++ b/src/transfer-risk/transfer-risk.ts @@ -45,6 +45,46 @@ export const getLegTransferRisk = ( ); }; +/** + * Stamps `transferRisk` on each transit leg the trip is at risk of missing. + * + * The risk sits on the boarding leg rather than the leg before the gap: + * clients filter insignificant foot legs out of the display but never transit + * legs, so a warning here cannot be filtered away. + * + * Always overwrites, including with `undefined`. Clients round-trip the whole + * trip pattern back to the server, so a leg that fails to refresh arrives + * carrying the risk from an earlier response; leaving it in place would keep a + * warning on screen after the delay behind it had cleared. + */ +export const withTransferRisk = < + T extends TransferLeg & {transferRisk?: TransferRisk}, +>( + legs: T[], +): T[] => + legs.map((leg, index) => ({ + ...leg, + transferRisk: getLegTransferRisk(legs, index), + })); + +/** + * The worst transfer risk across a trip, for a trip-level field. Computed from + * the legs rather than read off `transferRisk`, so it does not depend on + * `withTransferRisk` having run first. + * + * There is one level today, so the first risky transfer is the worst — add a + * severity comparison here if a second level is introduced. + */ +export const getTripTransferRisk = ( + legs: TransferLeg[], +): TransferRisk | undefined => { + for (let index = 0; index < legs.length; index++) { + const risk = getLegTransferRisk(legs, index); + if (risk) return risk; + } + return undefined; +}; + /** * 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)