anthodev/lapin-futepublic Fork
⑂ 2635f28
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(companion): pool partial-service arrivals and move store assets

Cedric ANTHONY committed 2026-09-14T08:27:00+02:00 Browse files
2635f28 parent: 59caea0
deleted .github/store-assets/emery_01-favorites.png +0 -0
deleted file mode 100644
Binary files a/.github/store-assets/emery_01-favorites.png and /dev/null differ
deleted file mode 100644
Binary files a/.github/store-assets/emery_01-favorites.png and /dev/null differBinary files a/.github/store-assets/emery_01-favorites.png and /dev/null differ
deleted .github/store-assets/emery_02-departures.png +0 -0
deleted file mode 100644
Binary files a/.github/store-assets/emery_02-departures.png and /dev/null differ
deleted file mode 100644
Binary files a/.github/store-assets/emery_02-departures.png and /dev/null differBinary files a/.github/store-assets/emery_02-departures.png and /dev/null differ
deleted .github/store-assets/emery_03-traffic.png +0 -0
deleted file mode 100644
Binary files a/.github/store-assets/emery_03-traffic.png and /dev/null differ
deleted file mode 100644
Binary files a/.github/store-assets/emery_03-traffic.png and /dev/null differBinary files a/.github/store-assets/emery_03-traffic.png and /dev/null differ
deleted .github/store-assets/gabbro_01-favorites.png +0 -0
deleted file mode 100644
Binary files a/.github/store-assets/gabbro_01-favorites.png and /dev/null differ
deleted file mode 100644
Binary files a/.github/store-assets/gabbro_01-favorites.png and /dev/null differBinary files a/.github/store-assets/gabbro_01-favorites.png and /dev/null differ
deleted .github/store-assets/gabbro_02-departures.png +0 -0
deleted file mode 100644
Binary files a/.github/store-assets/gabbro_02-departures.png and /dev/null differ
deleted file mode 100644
Binary files a/.github/store-assets/gabbro_02-departures.png and /dev/null differBinary files a/.github/store-assets/gabbro_02-departures.png and /dev/null differ
deleted .github/store-assets/gabbro_03-traffic.png +0 -0
deleted file mode 100644
Binary files a/.github/store-assets/gabbro_03-traffic.png and /dev/null differ
deleted file mode 100644
Binary files a/.github/store-assets/gabbro_03-traffic.png and /dev/null differBinary files a/.github/store-assets/gabbro_03-traffic.png and /dev/null differ
modified README.md +9 -1
@@ -24,13 +24,21 @@
2424 <p align="center">
2525 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
2626 <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">
27- <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.1-FF4938" alt="Lapin Futé 1.0.1 on RePebble"></a>
2827 </p>
2928
3029 ## About
3130
3231 Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite stops with their next arrivals, an arrival board for each, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.
3332
33+<p align="center">
34+ <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%">
35+ <img src="assets/gabbro_01-favorites.png" alt="Lapin Futé favorites on Pebble Round 2" width="45%">
36+</p>
37+<p align="center">
38+ <img src="assets/emery_02-departures.png" alt="Lapin Futé arrival board on Pebble Time 2" width="45%">
39+ <img src="assets/gabbro_02-departures.png" alt="Lapin Futé arrival board on Pebble Round 2" width="45%">
40+</p>
41+
3442 ## Features
3543
3644 - **Six favorite stops, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorite stops are renamed and reordered on the phone page, which shows official line badges and colors.
@@ -24,13 +24,21 @@
24 <p align="center">24 <p align="center">
25 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">25 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
26 <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">26 <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">
27- <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.1-FF4938" alt="Lapin Futé 1.0.1 on RePebble"></a>
28 </p>27 </p>
29 28
30 ## About29 ## About
31 30
32 Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite stops with their next arrivals, an arrival board for each, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.31 Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite stops with their next arrivals, an arrival board for each, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.
33 32
33+<p align="center">
34+ <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%">
35+ <img src="assets/gabbro_01-favorites.png" alt="Lapin Futé favorites on Pebble Round 2" width="45%">
36+</p>
37+<p align="center">
38+ <img src="assets/emery_02-departures.png" alt="Lapin Futé arrival board on Pebble Time 2" width="45%">
39+ <img src="assets/gabbro_02-departures.png" alt="Lapin Futé arrival board on Pebble Round 2" width="45%">
40+</p>
41+
34 ## Features42 ## Features
35 43
36 - **Six favorite stops, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorite stops are renamed and reordered on the phone page, which shows official line badges and colors.44 - **Six favorite stops, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorite stops are renamed and reordered on the phone page, which shows official line badges and colors.
renamed assets/README.md +5 -5
similarity index 64%
rename from .github/store-assets/README.md
rename to assets/README.md
@@ -12,11 +12,11 @@ These files are prepared for the RePebble app listing:
1212 | `gabbro_03-traffic.png` | 260 × 260 | Traffic status |
1313 | `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner |
1414
15-The screenshots are emulator framebuffer captures made with the project's
16-fixture data. The Emery captures have been normalized from the emulator's
17-backlight-off palette to its full-backlight palette; their UI content is
18-unchanged. They demonstrate the app UI but are not evidence of a production-data
19-or physical-watch test.
15+The screenshots are unretouched emulator framebuffer captures made with the
16+project's fixture data (fixture-only harness, zero live PRIM calls). The
17+favorites view shows a bus line and a metro line; the departures captures show
18+the same favorite and countdown values on both targets. They demonstrate the
19+app UI but are not evidence of a production-data or physical-watch test.
2020
2121 The banner is derived from the existing Lapin Futé visual identity and contains
2222 no screenshot or watch mock-up.
similarity index 64%
rename from .github/store-assets/README.md
rename to assets/README.md
@@ -12,11 +12,11 @@ These files are prepared for the RePebble app listing:
12 | `gabbro_03-traffic.png` | 260 × 260 | Traffic status |12 | `gabbro_03-traffic.png` | 260 × 260 | Traffic status |
13 | `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner |13 | `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner |
14 14
15-The screenshots are emulator framebuffer captures made with the project's15+The screenshots are unretouched emulator framebuffer captures made with the
16-fixture data. The Emery captures have been normalized from the emulator's16+project's fixture data (fixture-only harness, zero live PRIM calls). The
17-backlight-off palette to its full-backlight palette; their UI content is17+favorites view shows a bus line and a metro line; the departures captures show
18-unchanged. They demonstrate the app UI but are not evidence of a production-data18+the same favorite and countdown values on both targets. They demonstrate the
19-or physical-watch test.19+app UI but are not evidence of a production-data or physical-watch test.
20 20
21 The banner is derived from the existing Lapin Futé visual identity and contains21 The banner is derived from the existing Lapin Futé visual identity and contains
22 no screenshot or watch mock-up.22 no screenshot or watch mock-up.
added assets/emery_01-favorites.png +0 -0
new file mode 100644
Binary files /dev/null and b/assets/emery_01-favorites.png differ
new file mode 100644
Binary files /dev/null and b/assets/emery_01-favorites.png differBinary files /dev/null and b/assets/emery_01-favorites.png differ
added assets/emery_02-departures.png +0 -0
new file mode 100644
Binary files /dev/null and b/assets/emery_02-departures.png differ
new file mode 100644
Binary files /dev/null and b/assets/emery_02-departures.png differBinary files /dev/null and b/assets/emery_02-departures.png differ
added assets/emery_03-traffic.png +0 -0
new file mode 100644
Binary files /dev/null and b/assets/emery_03-traffic.png differ
new file mode 100644
Binary files /dev/null and b/assets/emery_03-traffic.png differBinary files /dev/null and b/assets/emery_03-traffic.png differ
added assets/gabbro_01-favorites.png +0 -0
new file mode 100644
Binary files /dev/null and b/assets/gabbro_01-favorites.png differ
new file mode 100644
Binary files /dev/null and b/assets/gabbro_01-favorites.png differBinary files /dev/null and b/assets/gabbro_01-favorites.png differ
added assets/gabbro_02-departures.png +0 -0
new file mode 100644
Binary files /dev/null and b/assets/gabbro_02-departures.png differ
new file mode 100644
Binary files /dev/null and b/assets/gabbro_02-departures.png differBinary files /dev/null and b/assets/gabbro_02-departures.png differ
added assets/gabbro_03-traffic.png +0 -0
new file mode 100644
Binary files /dev/null and b/assets/gabbro_03-traffic.png differ
new file mode 100644
Binary files /dev/null and b/assets/gabbro_03-traffic.png differBinary files /dev/null and b/assets/gabbro_03-traffic.png differ
renamed assets/lapin-fute-banner-720x320.png +0 -0
similarity index 100%
rename from .github/store-assets/lapin-fute-banner-720x320.png
rename to assets/lapin-fute-banner-720x320.png
similarity index 100%
rename from .github/store-assets/lapin-fute-banner-720x320.png
rename to assets/lapin-fute-banner-720x320.png
modified packages/companion/src/prim-departures.js +21 -12
@@ -4,6 +4,7 @@ var contracts = require("./contracts");
44 var RFC3339_EXPLICIT_OFFSET = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/;
55 var RETRY_AFTER_DELTA = /^[0-9]+$/;
66 var STOP_MONITORING_URL = "https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring";
7+var QUAY_MONITORING_PREFIX = "STIF:StopPoint:Q:";
78
89 function fail() {
910 throw new Error("Invalid PRIM stop-monitoring response");
@@ -75,7 +76,7 @@ function sourceStatus(value) {
7576 return "UNKNOWN";
7677 }
7778
78-function matchedJourney(visit, routing) {
79+function matchTier(visit, routing) {
7980 var monitoringRef = requiredRef(visit.MonitoringRef);
8081 var journey = visit.MonitoredVehicleJourney;
8182 var lineRef;
@@ -85,10 +86,14 @@ function matchedJourney(visit, routing) {
8586 if (!contracts.isObject(journey.MonitoredCall)) fail();
8687 if (monitoringRef !== routing.monitoringRef || lineRef !== routing.lineRef) return undefined;
8788 // SIRI DirectionRef is an operator label, not the catalog's GTFS direction_id.
88- // Only an exact terminal reference establishes the selected service.
89+ // A StopPoint:Q monitoring ref is a physical one-way quay, and PRIM labels
90+ // partial (short-turn) trips with the full-line terminal quay, so a differing
91+ // destination pools at tier 1 after the exact matches. StopArea refs (rail)
92+ // span both directions and keep the exact destination requirement.
8993 destinationRef = requiredRef(journey.DestinationRef);
90- if (destinationRef !== routing.destinationRef) return undefined;
91- return journey;
94+ if (destinationRef === routing.destinationRef) return 0;
95+ if (routing.monitoringRef.indexOf(QUAY_MONITORING_PREFIX) === 0) return 1;
96+ return undefined;
9297 }
9398
9499 function parseMatchedVisit(journey) {
@@ -105,11 +110,14 @@ function parseMatchedVisit(journey) {
105110 };
106111 }
107112
108-// Validate every visit, but retain only the first four in chronological order.
109-// Equal timestamps keep source order without relying on an ES5 engine's sort stability.
113+// Validate every visit, but retain only the first four, exact-destination
114+// matches first and then chronologically. Ties keep source order without
115+// relying on an ES5 engine's sort stability.
110116 function insertBounded(visits, visit) {
111117 var index = visits.length;
112- while (index > 0 && visits[index - 1].expectedAt > visit.expectedAt) index -= 1;
118+ while (index > 0 &&
119+ (visits[index - 1].tier > visit.tier ||
120+ (visits[index - 1].tier === visit.tier && visits[index - 1].expectedAt > visit.expectedAt))) index -= 1;
113121 if (index >= contracts.LIMITS.departures) return;
114122 visits.splice(index, 0, visit);
115123 if (visits.length > contracts.LIMITS.departures) visits.pop();
@@ -123,7 +131,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
123131 var responseTimestamp;
124132 var visits;
125133 var visit;
126- var journey;
134+ var tier;
127135 var recordedAt;
128136 var matched = [];
129137 var departures = [];
@@ -158,13 +166,14 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
158166 if (!contracts.isObject(visit)) fail();
159167 visitCount += 1;
160168 recordedAt = epochSeconds(visit.RecordedAtTime);
161- journey = matchedJourney(visit, routing);
162- if (typeof journey === "undefined") continue;
169+ tier = matchTier(visit, routing);
170+ if (typeof tier === "undefined") continue;
163171 if (typeof sourceUpdatedAt === "undefined" || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt;
164- insertBounded(matched, parseMatchedVisit(journey));
172+ visit = parseMatchedVisit(visit.MonitoredVehicleJourney);
173+ visit.tier = tier;
174+ insertBounded(matched, visit);
165175 }
166176 }
167- if (matched.length === 0 && visitCount > 0) fail();
168177 for (index = 0; index < matched.length; index += 1) {
169178 visit = matched[index];
170179 if (visit.hasExpected) realtimeCount += 1;
@@ -4,6 +4,7 @@ var contracts = require("./contracts");
4 var RFC3339_EXPLICIT_OFFSET = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/;4 var RFC3339_EXPLICIT_OFFSET = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/;
5 var RETRY_AFTER_DELTA = /^[0-9]+$/;5 var RETRY_AFTER_DELTA = /^[0-9]+$/;
6 var STOP_MONITORING_URL = "https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring";6 var STOP_MONITORING_URL = "https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring";
7+var QUAY_MONITORING_PREFIX = "STIF:StopPoint:Q:";
7 8
8 function fail() {9 function fail() {
9 throw new Error("Invalid PRIM stop-monitoring response");10 throw new Error("Invalid PRIM stop-monitoring response");
@@ -75,7 +76,7 @@ function sourceStatus(value) {
75 return "UNKNOWN";76 return "UNKNOWN";
76 }77 }
77 78
78-function matchedJourney(visit, routing) {79+function matchTier(visit, routing) {
79 var monitoringRef = requiredRef(visit.MonitoringRef);80 var monitoringRef = requiredRef(visit.MonitoringRef);
80 var journey = visit.MonitoredVehicleJourney;81 var journey = visit.MonitoredVehicleJourney;
81 var lineRef;82 var lineRef;
@@ -85,10 +86,14 @@ function matchedJourney(visit, routing) {
85 if (!contracts.isObject(journey.MonitoredCall)) fail();86 if (!contracts.isObject(journey.MonitoredCall)) fail();
86 if (monitoringRef !== routing.monitoringRef || lineRef !== routing.lineRef) return undefined;87 if (monitoringRef !== routing.monitoringRef || lineRef !== routing.lineRef) return undefined;
87 // SIRI DirectionRef is an operator label, not the catalog's GTFS direction_id.88 // SIRI DirectionRef is an operator label, not the catalog's GTFS direction_id.
88- // Only an exact terminal reference establishes the selected service.89+ // A StopPoint:Q monitoring ref is a physical one-way quay, and PRIM labels
90+ // partial (short-turn) trips with the full-line terminal quay, so a differing
91+ // destination pools at tier 1 after the exact matches. StopArea refs (rail)
92+ // span both directions and keep the exact destination requirement.
89 destinationRef = requiredRef(journey.DestinationRef);93 destinationRef = requiredRef(journey.DestinationRef);
90- if (destinationRef !== routing.destinationRef) return undefined;94+ if (destinationRef === routing.destinationRef) return 0;
91- return journey;95+ if (routing.monitoringRef.indexOf(QUAY_MONITORING_PREFIX) === 0) return 1;
96+ return undefined;
92 }97 }
93 98
94 function parseMatchedVisit(journey) {99 function parseMatchedVisit(journey) {
@@ -105,11 +110,14 @@ function parseMatchedVisit(journey) {
105 };110 };
106 }111 }
107 112
108-// Validate every visit, but retain only the first four in chronological order.113+// Validate every visit, but retain only the first four, exact-destination
109-// Equal timestamps keep source order without relying on an ES5 engine's sort stability.114+// matches first and then chronologically. Ties keep source order without
115+// relying on an ES5 engine's sort stability.
110 function insertBounded(visits, visit) {116 function insertBounded(visits, visit) {
111 var index = visits.length;117 var index = visits.length;
112- while (index > 0 && visits[index - 1].expectedAt > visit.expectedAt) index -= 1;118+ while (index > 0 &&
119+ (visits[index - 1].tier > visit.tier ||
120+ (visits[index - 1].tier === visit.tier && visits[index - 1].expectedAt > visit.expectedAt))) index -= 1;
113 if (index >= contracts.LIMITS.departures) return;121 if (index >= contracts.LIMITS.departures) return;
114 visits.splice(index, 0, visit);122 visits.splice(index, 0, visit);
115 if (visits.length > contracts.LIMITS.departures) visits.pop();123 if (visits.length > contracts.LIMITS.departures) visits.pop();
@@ -123,7 +131,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
123 var responseTimestamp;131 var responseTimestamp;
124 var visits;132 var visits;
125 var visit;133 var visit;
126- var journey;134+ var tier;
127 var recordedAt;135 var recordedAt;
128 var matched = [];136 var matched = [];
129 var departures = [];137 var departures = [];
@@ -158,13 +166,14 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
158 if (!contracts.isObject(visit)) fail();166 if (!contracts.isObject(visit)) fail();
159 visitCount += 1;167 visitCount += 1;
160 recordedAt = epochSeconds(visit.RecordedAtTime);168 recordedAt = epochSeconds(visit.RecordedAtTime);
161- journey = matchedJourney(visit, routing);169+ tier = matchTier(visit, routing);
162- if (typeof journey === "undefined") continue;170+ if (typeof tier === "undefined") continue;
163 if (typeof sourceUpdatedAt === "undefined" || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt;171 if (typeof sourceUpdatedAt === "undefined" || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt;
164- insertBounded(matched, parseMatchedVisit(journey));172+ visit = parseMatchedVisit(visit.MonitoredVehicleJourney);
173+ visit.tier = tier;
174+ insertBounded(matched, visit);
165 }175 }
166 }176 }
167- if (matched.length === 0 && visitCount > 0) fail();
168 for (index = 0; index < matched.length; index += 1) {177 for (index = 0; index < matched.length; index += 1) {
169 visit = matched[index];178 visit = matched[index];
170 if (visit.hasExpected) realtimeCount += 1;179 if (visit.hasExpected) realtimeCount += 1;
modified packages/companion/test/prim-normalization.test.js +62 -2
@@ -45,6 +45,23 @@ function snapshot(freshness, entries, sourceUpdatedAt) {
4545 };
4646 }
4747
48+function quayPayload(visitList) {
49+ return { Siri: { ServiceDelivery: { ResponseTimestamp: "2026-01-15T08:30:00Z",
50+ StopMonitoringDelivery: [{ ResponseTimestamp: "2026-01-15T08:30:00Z", MonitoredStopVisit: visitList }] } } };
51+}
52+
53+function quayVisit(monitoringRef, lineRef, destinationRef, expectedIso) {
54+ return {
55+ RecordedAtTime: "2026-01-15T08:29:00Z",
56+ MonitoringRef: { value: monitoringRef },
57+ MonitoredVehicleJourney: {
58+ LineRef: { value: lineRef },
59+ DestinationRef: { value: destinationRef },
60+ MonitoredCall: { ExpectedDepartureTime: expectedIso, DepartureStatus: "ontime" }
61+ }
62+ };
63+}
64+
4865 function visits(payload) {
4966 return payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit;
5067 }
@@ -171,14 +188,57 @@ test("exact terminal routing ignores Retour, blank and absent SIRI direction lab
171188 opposite.MonitoredVehicleJourney.DestinationRef = { value: "STIF:StopPoint:Q:99999:" };
172189 opposite.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime = "2026-01-15T08:31:00Z";
173190 visits(payload).push(opposite);
191+ // Quay refs pool sibling destinations after the exact matches.
174192 assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
175- snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00")]));
193+ snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00"), departure("08:31:00", 1, "ON_TIME", "08:33:00")],
194+ Date.parse("2026-01-15T12:00:00Z") / 1000));
176195 journey.DestinationRef = opposite.MonitoredVehicleJourney.DestinationRef;
177- assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); });
196+ // Zero exact matches at a quay ref still pools instead of failing.
197+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
198+ snapshot("REALTIME", [departure("08:31:00", 1, "ON_TIME", "08:33:00", 3), departure("08:34:00", 4, "ON_TIME", "08:33:00")],
199+ Date.parse("2026-01-15T12:00:00Z") / 1000));
178200 delete journey.DestinationRef;
179201 assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); });
180202 });
181203
204+test("quay monitoring pools sibling destinations after exact matches", function () {
205+ var quay = "STIF:StopPoint:Q:25433:";
206+ var line = "STIF:Line::C01246:";
207+ var refs = {
208+ monitoringRef: quay,
209+ lineRef: line,
210+ destinationRef: "STIF:StopPoint:Q:493344:"
211+ };
212+ var payload = quayPayload([
213+ quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:42:00Z"),
214+ quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:50:00Z"),
215+ quayVisit(quay, line, refs.destinationRef, "2026-01-15T08:35:00Z"),
216+ quayVisit(quay, line, refs.destinationRef, "2026-01-15T09:00:00Z"),
217+ quayVisit("STIF:StopPoint:Q:99999:", line, refs.destinationRef, "2026-01-15T08:36:00Z")
218+ ]);
219+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
220+ snapshot("REALTIME", [
221+ departure("08:35:00", 5, "ON_TIME", undefined, 25),
222+ departure("09:00:00", 30, "ON_TIME"),
223+ departure("08:42:00", 12, "ON_TIME", undefined, 8),
224+ departure("08:50:00", 20, "ON_TIME")
225+ ]));
226+});
227+
228+test("area monitoring stays strict and yields an empty snapshot instead of failing", function () {
229+ var refs = {
230+ monitoringRef: "STIF:StopArea:SP:4001:",
231+ lineRef: "STIF:Line::C005:",
232+ destinationRef: "STIF:StopArea:SP:43219:"
233+ };
234+ var payload = quayPayload([
235+ quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:99999:", "2026-01-15T08:36:00Z"),
236+ quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:98888:", "2026-01-15T08:40:00Z")
237+ ]);
238+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
239+ snapshot("SCHEDULED", []));
240+});
241+
182242 test("explicit offsets are independent of local timezone and malformed timestamps fail closed", function () {
183243 var payload = fixture("metro");
184244 var call = visits(payload)[0].MonitoredVehicleJourney.MonitoredCall;
@@ -45,6 +45,23 @@ function snapshot(freshness, entries, sourceUpdatedAt) {
45 };45 };
46 }46 }
47 47
48+function quayPayload(visitList) {
49+ return { Siri: { ServiceDelivery: { ResponseTimestamp: "2026-01-15T08:30:00Z",
50+ StopMonitoringDelivery: [{ ResponseTimestamp: "2026-01-15T08:30:00Z", MonitoredStopVisit: visitList }] } } };
51+}
52+
53+function quayVisit(monitoringRef, lineRef, destinationRef, expectedIso) {
54+ return {
55+ RecordedAtTime: "2026-01-15T08:29:00Z",
56+ MonitoringRef: { value: monitoringRef },
57+ MonitoredVehicleJourney: {
58+ LineRef: { value: lineRef },
59+ DestinationRef: { value: destinationRef },
60+ MonitoredCall: { ExpectedDepartureTime: expectedIso, DepartureStatus: "ontime" }
61+ }
62+ };
63+}
64+
48 function visits(payload) {65 function visits(payload) {
49 return payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit;66 return payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit;
50 }67 }
@@ -171,14 +188,57 @@ test("exact terminal routing ignores Retour, blank and absent SIRI direction lab
171 opposite.MonitoredVehicleJourney.DestinationRef = { value: "STIF:StopPoint:Q:99999:" };188 opposite.MonitoredVehicleJourney.DestinationRef = { value: "STIF:StopPoint:Q:99999:" };
172 opposite.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime = "2026-01-15T08:31:00Z";189 opposite.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime = "2026-01-15T08:31:00Z";
173 visits(payload).push(opposite);190 visits(payload).push(opposite);
191+ // Quay refs pool sibling destinations after the exact matches.
174 assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),192 assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
175- snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00")]));193+ snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00"), departure("08:31:00", 1, "ON_TIME", "08:33:00")],
194+ Date.parse("2026-01-15T12:00:00Z") / 1000));
176 journey.DestinationRef = opposite.MonitoredVehicleJourney.DestinationRef;195 journey.DestinationRef = opposite.MonitoredVehicleJourney.DestinationRef;
177- assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); });196+ // Zero exact matches at a quay ref still pools instead of failing.
197+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
198+ snapshot("REALTIME", [departure("08:31:00", 1, "ON_TIME", "08:33:00", 3), departure("08:34:00", 4, "ON_TIME", "08:33:00")],
199+ Date.parse("2026-01-15T12:00:00Z") / 1000));
178 delete journey.DestinationRef;200 delete journey.DestinationRef;
179 assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); });201 assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); });
180 });202 });
181 203
204+test("quay monitoring pools sibling destinations after exact matches", function () {
205+ var quay = "STIF:StopPoint:Q:25433:";
206+ var line = "STIF:Line::C01246:";
207+ var refs = {
208+ monitoringRef: quay,
209+ lineRef: line,
210+ destinationRef: "STIF:StopPoint:Q:493344:"
211+ };
212+ var payload = quayPayload([
213+ quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:42:00Z"),
214+ quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:50:00Z"),
215+ quayVisit(quay, line, refs.destinationRef, "2026-01-15T08:35:00Z"),
216+ quayVisit(quay, line, refs.destinationRef, "2026-01-15T09:00:00Z"),
217+ quayVisit("STIF:StopPoint:Q:99999:", line, refs.destinationRef, "2026-01-15T08:36:00Z")
218+ ]);
219+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
220+ snapshot("REALTIME", [
221+ departure("08:35:00", 5, "ON_TIME", undefined, 25),
222+ departure("09:00:00", 30, "ON_TIME"),
223+ departure("08:42:00", 12, "ON_TIME", undefined, 8),
224+ departure("08:50:00", 20, "ON_TIME")
225+ ]));
226+});
227+
228+test("area monitoring stays strict and yields an empty snapshot instead of failing", function () {
229+ var refs = {
230+ monitoringRef: "STIF:StopArea:SP:4001:",
231+ lineRef: "STIF:Line::C005:",
232+ destinationRef: "STIF:StopArea:SP:43219:"
233+ };
234+ var payload = quayPayload([
235+ quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:99999:", "2026-01-15T08:36:00Z"),
236+ quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:98888:", "2026-01-15T08:40:00Z")
237+ ]);
238+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT),
239+ snapshot("SCHEDULED", []));
240+});
241+
182 test("explicit offsets are independent of local timezone and malformed timestamps fail closed", function () {242 test("explicit offsets are independent of local timezone and malformed timestamps fail closed", function () {
183 var payload = fixture("metro");243 var payload = fixture("metro");
184 var call = visits(payload)[0].MonitoredVehicleJourney.MonitoredCall;244 var call = visits(payload)[0].MonitoredVehicleJourney.MonitoredCall;
modified packages/config-page/src/config-core.js +2 -0
@@ -121,6 +121,7 @@ export const COPY = {
121121 favoriteRename: "Rename",
122122 favoriteRemove: "Remove",
123123 favoriteUnresolved: "Not found in the catalog anymore: remove it, then search for the stop again.",
124+ siblingQuay: "Shares this quay and direction: passages are pooled and each favorite prioritizes its own destination.",
124125 renamePrompt: "Favorite name",
125126 save: "Save settings",
126127 aboutOpen: "About",
@@ -185,6 +186,7 @@ export const COPY = {
185186 favoriteRemove: "Supprimer",
186187 renamePrompt: "Nom du favori",
187188 favoriteUnresolved: "Introuvable dans le catalogue : supprimez-le, puis recherchez à nouveau l’arrêt.",
189+ siblingQuay: "Partage ce quai et ce sens : les passages sont mutualisés et chaque favori priorise sa destination.",
188190 save: "Enregistrer les réglages",
189191 aboutOpen: "À propos",
190192 aboutBack: "Retour aux réglages",
@@ -121,6 +121,7 @@ export const COPY = {
121 favoriteRename: "Rename",121 favoriteRename: "Rename",
122 favoriteRemove: "Remove",122 favoriteRemove: "Remove",
123 favoriteUnresolved: "Not found in the catalog anymore: remove it, then search for the stop again.",123 favoriteUnresolved: "Not found in the catalog anymore: remove it, then search for the stop again.",
124+ siblingQuay: "Shares this quay and direction: passages are pooled and each favorite prioritizes its own destination.",
124 renamePrompt: "Favorite name",125 renamePrompt: "Favorite name",
125 save: "Save settings",126 save: "Save settings",
126 aboutOpen: "About",127 aboutOpen: "About",
@@ -185,6 +186,7 @@ export const COPY = {
185 favoriteRemove: "Supprimer",186 favoriteRemove: "Supprimer",
186 renamePrompt: "Nom du favori",187 renamePrompt: "Nom du favori",
187 favoriteUnresolved: "Introuvable dans le catalogue : supprimez-le, puis recherchez à nouveau l’arrêt.",188 favoriteUnresolved: "Introuvable dans le catalogue : supprimez-le, puis recherchez à nouveau l’arrêt.",
189+ siblingQuay: "Partage ce quai et ce sens : les passages sont mutualisés et chaque favori priorise sa destination.",
188 save: "Enregistrer les réglages",190 save: "Enregistrer les réglages",
189 aboutOpen: "À propos",191 aboutOpen: "À propos",
190 aboutBack: "Retour aux réglages",192 aboutBack: "Retour aux réglages",
modified packages/config-page/src/config-page.js +12 -0
@@ -229,6 +229,12 @@ function renderFavorites() {
229229 const available = LIMITS.favorites - used;
230230 setText(elements.favorites_count,
231231 `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`);
232+ const sharedQuays = new Map();
233+ state.favorites.forEach((favorite) => {
234+ if (!Object.hasOwn(favorite, "routing")) return;
235+ const quay = favorite.routing.monitoringRef;
236+ sharedQuays.set(quay, (sharedQuays.get(quay) ?? 0) + 1);
237+ });
232238
233239 state.favorites.forEach((favorite, index) => {
234240 const item = document.createElement("li");
@@ -253,6 +259,12 @@ function renderFavorites() {
253259 unresolved.textContent = copy.favoriteUnresolved;
254260 labels.append(unresolved);
255261 }
262+ if (Object.hasOwn(favorite, "routing") && sharedQuays.get(favorite.routing.monitoringRef) > 1) {
263+ const sibling = document.createElement("span");
264+ sibling.className = "favorite-sibling";
265+ sibling.textContent = copy.siblingQuay;
266+ labels.append(sibling);
267+ }
256268 const routeRow = document.createElement("div");
257269 routeRow.className = "favorite-route";
258270 routeRow.append(route, labels);
@@ -229,6 +229,12 @@ function renderFavorites() {
229 const available = LIMITS.favorites - used;229 const available = LIMITS.favorites - used;
230 setText(elements.favorites_count,230 setText(elements.favorites_count,
231 `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`);231 `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`);
232+ const sharedQuays = new Map();
233+ state.favorites.forEach((favorite) => {
234+ if (!Object.hasOwn(favorite, "routing")) return;
235+ const quay = favorite.routing.monitoringRef;
236+ sharedQuays.set(quay, (sharedQuays.get(quay) ?? 0) + 1);
237+ });
232 238
233 state.favorites.forEach((favorite, index) => {239 state.favorites.forEach((favorite, index) => {
234 const item = document.createElement("li");240 const item = document.createElement("li");
@@ -253,6 +259,12 @@ function renderFavorites() {
253 unresolved.textContent = copy.favoriteUnresolved;259 unresolved.textContent = copy.favoriteUnresolved;
254 labels.append(unresolved);260 labels.append(unresolved);
255 }261 }
262+ if (Object.hasOwn(favorite, "routing") && sharedQuays.get(favorite.routing.monitoringRef) > 1) {
263+ const sibling = document.createElement("span");
264+ sibling.className = "favorite-sibling";
265+ sibling.textContent = copy.siblingQuay;
266+ labels.append(sibling);
267+ }
256 const routeRow = document.createElement("div");268 const routeRow = document.createElement("div");
257 routeRow.className = "favorite-route";269 routeRow.className = "favorite-route";
258 routeRow.append(route, labels);270 routeRow.append(route, labels);
modified packages/config-page/styles/config-page.css +1 -0
@@ -221,6 +221,7 @@ button:disabled { cursor: default; opacity: 0.35; }
221221 .route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); }
222222 .route-stop::before { background: var(--muted); }
223223 .unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; }
224+.favorite-sibling { display: block; margin-top: 0.25rem; color: var(--muted); font-size: 0.75rem; line-height: 1.5; }
224225 .favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; }
225226 .favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; }
226227 .favorite-actions { display: flex; }
@@ -221,6 +221,7 @@ button:disabled { cursor: default; opacity: 0.35; }
221 .route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); }221 .route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); }
222 .route-stop::before { background: var(--muted); }222 .route-stop::before { background: var(--muted); }
223 .unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; }223 .unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; }
224+.favorite-sibling { display: block; margin-top: 0.25rem; color: var(--muted); font-size: 0.75rem; line-height: 1.5; }
224 .favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; }225 .favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; }
225 .favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; }226 .favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; }
226 .favorite-actions { display: flex; }227 .favorite-actions { display: flex; }