Skip to content

Commit fbd1904

Browse files
reidzeibelCopilot
andauthored
feat!: only use uncertain transfer risk (#64)
* feat!: only use `uncertain` transfer risk * refactor: update wording Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feat: share transfer risk stamping and trip aggregation --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent aea42be commit fbd1904

4 files changed

Lines changed: 153 additions & 31 deletions

File tree

src/transfer-risk/__tests__/transfer-risk.test.ts

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
import {
22
getTransferRisk,
33
getLegTransferRisk,
4+
getTripTransferRisk,
5+
withTransferRisk,
46
isTransitLeg,
5-
UNLIKELY_TRANSFER_LIMIT_IN_SECONDS,
7+
TransferRisk,
68
type TransferLeg,
79
} from '..';
810

9-
const transitLeg = (overrides: Partial<TransferLeg> = {}): TransferLeg => ({
11+
/** A leg that can carry a stamped risk, as every real consumer's leg can. */
12+
type TestLeg = TransferLeg & {transferRisk?: TransferRisk};
13+
14+
const transitLeg = (overrides: Partial<TestLeg> = {}): TestLeg => ({
1015
aimedStartTime: '2024-01-01T10:00:00.000Z',
1116
expectedStartTime: '2024-01-01T10:00:00.000Z',
1217
expectedEndTime: '2024-01-01T10:10:00.000Z',
1318
serviceJourney: {id: 'ATB:ServiceJourney:1'},
1419
...overrides,
1520
});
1621

17-
const footLeg = (overrides: Partial<TransferLeg> = {}): TransferLeg => ({
22+
const footLeg = (overrides: Partial<TestLeg> = {}): TestLeg => ({
1823
aimedStartTime: '2024-01-01T10:10:00.000Z',
1924
expectedStartTime: '2024-01-01T10:10:00.000Z',
2025
expectedEndTime: '2024-01-01T10:15:00.000Z',
@@ -32,18 +37,10 @@ describe('getTransferRisk', () => {
3237
expect(getTransferRisk(0)).toBe('uncertain');
3338
});
3439

35-
it('is uncertain down to the unlikely limit', () => {
40+
it('is uncertain at any negative gap, however large', () => {
41+
expect(getTransferRisk(-1)).toBe('uncertain');
3642
expect(getTransferRisk(-60)).toBe('uncertain');
37-
expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS)).toBe(
38-
'uncertain',
39-
);
40-
});
41-
42-
it('is unlikely past the limit', () => {
43-
expect(getTransferRisk(UNLIKELY_TRANSFER_LIMIT_IN_SECONDS - 1)).toBe(
44-
'unlikely',
45-
);
46-
expect(getTransferRisk(-600)).toBe('unlikely');
43+
expect(getTransferRisk(-600)).toBe('uncertain');
4744
});
4845

4946
it('passes when the gap is not a finite number', () => {
@@ -68,12 +65,12 @@ describe('getLegTransferRisk', () => {
6865
expect(getLegTransferRisk(legs, 1)).toBe('uncertain');
6966
});
7067

71-
it('reports unlikely once the gap is past the limit', () => {
68+
it('stays uncertain on a badly missed transfer', () => {
7269
const legs = [
7370
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
7471
transitLeg({expectedStartTime: '2024-01-01T10:05:00.000Z'}),
7572
];
76-
expect(getLegTransferRisk(legs, 1)).toBe('unlikely');
73+
expect(getLegTransferRisk(legs, 1)).toBe('uncertain');
7774
});
7875

7976
it('passes when there is time to spare', () => {
@@ -117,7 +114,7 @@ describe('getLegTransferRisk', () => {
117114
}),
118115
transitLeg({expectedStartTime: '2024-01-01T10:11:00.000Z'}),
119116
];
120-
expect(getLegTransferRisk(legs, 2)).toBe('unlikely');
117+
expect(getLegTransferRisk(legs, 2)).toBe('uncertain');
121118
});
122119

123120
it('passes when the gap is unparseable rather than inventing a risk', () => {
@@ -207,7 +204,7 @@ describe('getLegTransferRisk', () => {
207204
}),
208205
];
209206
// Held until 10:13, but we do not arrive until 10:20.
210-
expect(getLegTransferRisk(legs, 1)).toBe('unlikely');
207+
expect(getLegTransferRisk(legs, 1)).toBe('uncertain');
211208
});
212209

213210
it('counts an intervening walk against the maximum wait time', () => {
@@ -226,7 +223,7 @@ describe('getLegTransferRisk', () => {
226223
}),
227224
];
228225
// Held until 10:13, but the walk does not end until 10:15.
229-
expect(getLegTransferRisk(legs, 2)).toBe('unlikely');
226+
expect(getLegTransferRisk(legs, 2)).toBe('uncertain');
230227
});
231228

232229
it('keeps the guarantee when the deadline is unparseable', () => {
@@ -244,3 +241,87 @@ describe('getLegTransferRisk', () => {
244241
});
245242
});
246243
});
244+
245+
describe('withTransferRisk', () => {
246+
it('stamps the leg you might miss, not the one before', () => {
247+
const legs = withTransferRisk([
248+
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
249+
transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}),
250+
]);
251+
expect(legs[0].transferRisk).toBeUndefined();
252+
expect(legs[1].transferRisk).toBe('uncertain');
253+
});
254+
255+
it('leaves a comfortable transfer unstamped', () => {
256+
const legs = withTransferRisk([
257+
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
258+
transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}),
259+
]);
260+
expect(legs.every((leg) => leg.transferRisk === undefined)).toBe(true);
261+
});
262+
263+
it('clears a risk the caller passed back in, once the gap is fine', () => {
264+
const legs = withTransferRisk([
265+
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
266+
transitLeg({
267+
expectedStartTime: '2024-01-01T10:15:00.000Z',
268+
transferRisk: TransferRisk.Uncertain,
269+
}),
270+
]);
271+
expect(legs[1].transferRisk).toBeUndefined();
272+
});
273+
274+
it('does not stamp a guaranteed transfer', () => {
275+
const legs = withTransferRisk([
276+
transitLeg({
277+
expectedEndTime: '2024-01-01T10:10:00.000Z',
278+
interchangeTo: {guaranteed: true},
279+
}),
280+
transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}),
281+
]);
282+
expect(legs[1].transferRisk).toBeUndefined();
283+
});
284+
});
285+
286+
describe('getTripTransferRisk', () => {
287+
it('passes when every transfer has time to spare', () => {
288+
const legs = [
289+
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
290+
transitLeg({expectedStartTime: '2024-01-01T10:15:00.000Z'}),
291+
];
292+
expect(getTripTransferRisk(legs)).toBeUndefined();
293+
});
294+
295+
it('reports a risk from anywhere in the trip', () => {
296+
const legs = [
297+
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
298+
transitLeg({
299+
expectedStartTime: '2024-01-01T10:15:00.000Z',
300+
expectedEndTime: '2024-01-01T10:25:00.000Z',
301+
}),
302+
transitLeg({expectedStartTime: '2024-01-01T10:24:00.000Z'}),
303+
];
304+
expect(getTripTransferRisk(legs)).toBe('uncertain');
305+
});
306+
307+
it('ignores a guaranteed transfer when looking across the trip', () => {
308+
const legs = [
309+
transitLeg({
310+
expectedEndTime: '2024-01-01T10:10:00.000Z',
311+
interchangeTo: {guaranteed: true},
312+
}),
313+
transitLeg({expectedStartTime: '2024-01-01T10:00:00.000Z'}),
314+
];
315+
expect(getTripTransferRisk(legs)).toBeUndefined();
316+
});
317+
318+
it('does not need the legs to be stamped first', () => {
319+
const legs = [
320+
transitLeg({expectedEndTime: '2024-01-01T10:10:00.000Z'}),
321+
transitLeg({expectedStartTime: '2024-01-01T10:09:00.000Z'}),
322+
];
323+
expect(getTripTransferRisk(legs)).toBe(
324+
getTripTransferRisk(withTransferRisk(legs)),
325+
);
326+
});
327+
});

src/transfer-risk/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
export {
22
getTransferRisk,
33
getLegTransferRisk,
4+
getTripTransferRisk,
5+
withTransferRisk,
46
isTransitLeg,
5-
UNLIKELY_TRANSFER_LIMIT_IN_SECONDS,
67
} from './transfer-risk';
7-
// Exports both the value (TransferRisk.Unlikely) and the type.
8+
// Exports both the value (TransferRisk.Uncertain) and the type.
89
export {TransferRisk} from './types';
910
export type {TransferLeg} from './types';

src/transfer-risk/transfer-risk.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
import type {TransferLeg} from './types';
22
import {TransferRisk} from './types';
33

4-
/** Below this, the transfer is not one to count on. */
5-
export const UNLIKELY_TRANSFER_LIMIT_IN_SECONDS = -120;
6-
74
/**
8-
* Classifies the gap between arriving and the next departure. Zero counts as
9-
* uncertain; a non-finite gap yields undefined.
5+
* Classifies the gap between arriving and the next departure: any gap that is
6+
* not positive is uncertain, however large. Zero counts, because arriving
7+
* exactly as the service leaves is not a transfer you can rely on. A
8+
* non-finite gap yields undefined.
109
*/
1110
export const getTransferRisk = (seconds: number): TransferRisk | undefined => {
1211
if (!Number.isFinite(seconds) || seconds > 0) {
1312
return undefined;
1413
}
15-
return seconds < UNLIKELY_TRANSFER_LIMIT_IN_SECONDS
16-
? TransferRisk.Unlikely
17-
: TransferRisk.Uncertain;
14+
return TransferRisk.Uncertain;
1815
};
1916

2017
/** Whether a leg is scheduled transit rather than walking, cycling and such. */
@@ -48,6 +45,46 @@ export const getLegTransferRisk = (
4845
);
4946
};
5047

48+
/**
49+
* Stamps `transferRisk` on each transit leg the trip is at risk of missing.
50+
*
51+
* The risk sits on the boarding leg rather than the leg before the gap:
52+
* clients filter insignificant foot legs out of the display but never transit
53+
* legs, so a warning here cannot be filtered away.
54+
*
55+
* Always overwrites, including with `undefined`. Clients round-trip the whole
56+
* trip pattern back to the server, so a leg that fails to refresh arrives
57+
* carrying the risk from an earlier response; leaving it in place would keep a
58+
* warning on screen after the delay behind it had cleared.
59+
*/
60+
export const withTransferRisk = <
61+
T extends TransferLeg & {transferRisk?: TransferRisk},
62+
>(
63+
legs: T[],
64+
): T[] =>
65+
legs.map((leg, index) => ({
66+
...leg,
67+
transferRisk: getLegTransferRisk(legs, index),
68+
}));
69+
70+
/**
71+
* The worst transfer risk across a trip, for a trip-level field. Computed from
72+
* the legs rather than read off `transferRisk`, so it does not depend on
73+
* `withTransferRisk` having run first.
74+
*
75+
* There is one level today, so the first risky transfer is the worst — add a
76+
* severity comparison here if a second level is introduced.
77+
*/
78+
export const getTripTransferRisk = (
79+
legs: TransferLeg[],
80+
): TransferRisk | undefined => {
81+
for (let index = 0; index < legs.length; index++) {
82+
const risk = getLegTransferRisk(legs, index);
83+
if (risk) return risk;
84+
}
85+
return undefined;
86+
};
87+
5188
/**
5289
* The transit leg you alight from, which carries the interchange. Walks back
5390
* past non-transit legs: bus -> walk -> bus is measured on the (walk, bus)

src/transfer-risk/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
/** How risky a transfer is when there is no time to spare. */
1+
/**
2+
* Transfer risk classification for transfers with no slack (0s) or already
3+
* missed (negative gaps). Kept as a string (vs boolean) because additional
4+
* levels (e.g. `shortWait`) may be added later.
5+
*/
26
export const TransferRisk = {
37
Uncertain: 'uncertain',
4-
Unlikely: 'unlikely',
58
} as const;
69

710
export type TransferRisk = (typeof TransferRisk)[keyof typeof TransferRisk];

0 commit comments

Comments
 (0)