anthodev/lapin-futepublic Fork
⑂ develop
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

feat(arrivals): support departure-to-arrival favorites (#28) #29

Merged@github-anthodev wants to merge feature/departure-arrival-journeys/arrivals/28 into develop
Merged as 102d71929e93.
64 files changed
modified README.md +10 -7
@@ -7,7 +7,7 @@
77 <p align="center">
88 <strong>Next arrivals and line traffic at a glance, on your Pebble.</strong>
99 <br>
10- Keep six favorite stops across Île-de-France and check them without reaching for your phone.
10+ Keep six favorite journeys across Île-de-France and check them without reaching for your phone.
1111 </p>
1212
1313 <p align="center">
@@ -28,7 +28,7 @@
2828
2929 ## About
3030
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.
31+Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite journeys 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.
3232
3333 <p align="center">
3434 <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%">
@@ -41,11 +41,14 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
4141
4242 ## Features
4343
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.
45-- **Arrivals at a glance.** The favorites list already shows the next arrivals for each stop; Select opens a full arrival board for one favorite. Countdowns keep ticking on the watch between refreshes.
44+> [!NOTE]
45+> Departure-to-arrival favorites, the uncertainty flag, and the journey-pattern catalog described below are implemented in this source tree but not published yet. They land together: a rebuilt station catalog, the matching settings page, and a rebuilt watch app are all required. The new on-watch rendering is not natively verified yet; the screenshots above show the currently published release.
46+
47+- **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
48+- **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes.
4649 - **Traffic view.** Select again on an arrival board to page through the current service messages for that line.
4750 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
48-- **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, and missing cached data.
51+- **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`).
4952 - **English and French.** The watch interface and the settings page are both bilingual.
5053 - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.
5154
@@ -62,7 +65,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
6265
6366 1. Install [Lapin Futé from RePebble](https://apps.repebble.com/6d6aa01b7ecb4cfea469a183).
6467 2. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
65-3. Add up to six favorite stops by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
68+3. Add up to six favorite journeys: search the departure stop, pick the line and boarding point, then pick a reachable arrival stop, including intermediate stops. Searching and adding work without a token. Save, and your settings sync to the watch. Favorites saved with an earlier version keep their name, order, and labels; their arrival is filled in automatically from the updated catalog, and until that succeeds they show as unresolved. A settings page opened by an older companion stays read-only until the watch app is updated.
6669 4. Open Lapin Futé on the watch. Arrivals refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
6770
6871 Developers can also build and install the app locally using the instructions in [Build from source](#build-from-source).
@@ -101,7 +104,7 @@ npm run build
101104 LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build
102105 ```
103106
104-The station catalog is generated ahead of time from open IDFM datasets. With `IDFM_DATASET_TOKEN` in `.env`:
107+The station catalog is generated ahead of time from open IDFM datasets. Format 4 embeds each line's journey patterns — stop sequences with boarding and alighting restrictions, plus the terminal aliases the phone matches against announced destinations — and there is no in-place upgrade: an existing catalog must be rebuilt and republished in full. Until that catalog is live, the settings page cannot list arrival stops or resolve migrated favorites, and on the watch a favorite whose arrival never resolved shows no departures while one whose line patterns are missing shows its departures as uncertain. With `IDFM_DATASET_TOKEN` in `.env`:
105108
106109 ```sh
107110 node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog
@@ -7,7 +7,7 @@
7 <p align="center">7 <p align="center">
8 <strong>Next arrivals and line traffic at a glance, on your Pebble.</strong>8 <strong>Next arrivals and line traffic at a glance, on your Pebble.</strong>
9 <br>9 <br>
10- Keep six favorite stops across Île-de-France and check them without reaching for your phone.10+ Keep six favorite journeys across Île-de-France and check them without reaching for your phone.
11 </p>11 </p>
12 12
13 <p align="center">13 <p align="center">
@@ -28,7 +28,7 @@
28 28
29 ## About29 ## About
30 30
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.31+Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite journeys 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.
32 32
33 <p align="center">33 <p align="center">
34 <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%">34 <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%">
@@ -41,11 +41,14 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
41 41
42 ## Features42 ## Features
43 43
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.44+> [!NOTE]
45-- **Arrivals at a glance.** The favorites list already shows the next arrivals for each stop; Select opens a full arrival board for one favorite. Countdowns keep ticking on the watch between refreshes.45+> Departure-to-arrival favorites, the uncertainty flag, and the journey-pattern catalog described below are implemented in this source tree but not published yet. They land together: a rebuilt station catalog, the matching settings page, and a rebuilt watch app are all required. The new on-watch rendering is not natively verified yet; the screenshots above show the currently published release.
46+
47+- **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
48+- **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes.
46 - **Traffic view.** Select again on an arrival board to page through the current service messages for that line.49 - **Traffic view.** Select again on an arrival board to page through the current service messages for that line.
47 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.50 - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
48-- **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, and missing cached data.51+- **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`).
49 - **English and French.** The watch interface and the settings page are both bilingual.52 - **English and French.** The watch interface and the settings page are both bilingual.
50 - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.53 - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.
51 54
@@ -62,7 +65,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The
62 65
63 1. Install [Lapin Futé from RePebble](https://apps.repebble.com/6d6aa01b7ecb4cfea469a183).66 1. Install [Lapin Futé from RePebble](https://apps.repebble.com/6d6aa01b7ecb4cfea469a183).
64 2. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).67 2. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
65-3. Add up to six favorite stops by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.68+3. Add up to six favorite journeys: search the departure stop, pick the line and boarding point, then pick a reachable arrival stop, including intermediate stops. Searching and adding work without a token. Save, and your settings sync to the watch. Favorites saved with an earlier version keep their name, order, and labels; their arrival is filled in automatically from the updated catalog, and until that succeeds they show as unresolved. A settings page opened by an older companion stays read-only until the watch app is updated.
66 4. Open Lapin Futé on the watch. Arrivals refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.69 4. Open Lapin Futé on the watch. Arrivals refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
67 70
68 Developers can also build and install the app locally using the instructions in [Build from source](#build-from-source).71 Developers can also build and install the app locally using the instructions in [Build from source](#build-from-source).
@@ -101,7 +104,7 @@ npm run build
101 LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build104 LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build
102 ```105 ```
103 106
104-The station catalog is generated ahead of time from open IDFM datasets. With `IDFM_DATASET_TOKEN` in `.env`:107+The station catalog is generated ahead of time from open IDFM datasets. Format 4 embeds each line's journey patterns — stop sequences with boarding and alighting restrictions, plus the terminal aliases the phone matches against announced destinations — and there is no in-place upgrade: an existing catalog must be rebuilt and republished in full. Until that catalog is live, the settings page cannot list arrival stops or resolve migrated favorites, and on the watch a favorite whose arrival never resolved shows no departures while one whose line patterns are missing shows its departures as uncertain. With `IDFM_DATASET_TOKEN` in `.env`:
105 108
106 ```sh109 ```sh
107 node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog110 node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog
modified fixtures/catalog/idfm-v1.json +47 -6
@@ -17,6 +17,47 @@
1717 { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },
1818 { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" }
1919 ],
20+ "journeySources": {
21+ "stops": [
22+ { "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_name": "Porte de Paris", "parent_station": "" },
23+ { "stop_id": "IDFM:BUS-MIDDLE", "stop_name": "Arrêt intermédiaire", "parent_station": "" },
24+ { "stop_id": "unmapped-stop", "stop_name": "Arrêt sans rattachement", "parent_station": "" }
25+ ],
26+ "arrets": [
27+ { "arrid": "TERM-BUS-PARTIAL", "zdaid": "44016", "arrname": "Mairie de Stains" },
28+ { "arrid": "7969", "zdaid": "44016", "arrname": "Mairie" }
29+ ],
30+ "trips": [
31+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-partial", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
32+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-express", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
33+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-loop", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
34+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-restricted", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
35+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-no-boarding", "trip_headsign": "Hôpital Européen", "direction_id": "0" }
36+ ],
37+ "stopTimes": [
38+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
39+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:BUS-MIDDLE", "stop_sequence": "2", "pickup_type": "0", "drop_off_type": "0" },
40+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_sequence": "3", "pickup_type": "0", "drop_off_type": "0" },
41+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "4", "pickup_type": "1", "drop_off_type": "0" },
42+ { "trip_id": "trip-bus-partial", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
43+ { "trip_id": "trip-bus-partial", "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_sequence": "2", "pickup_type": "1", "drop_off_type": "0" },
44+ { "trip_id": "trip-bus-express", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
45+ { "trip_id": "trip-bus-express", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "2", "pickup_type": "1", "drop_off_type": "0" },
46+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
47+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_sequence": "2", "pickup_type": "0", "drop_off_type": "0" },
48+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "3", "pickup_type": "0", "drop_off_type": "0" },
49+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "4", "pickup_type": "1", "drop_off_type": "0" },
50+ { "trip_id": "trip-bus-restricted", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "2", "drop_off_type": "0" },
51+ { "trip_id": "trip-bus-restricted", "stop_id": "unmapped-stop", "stop_sequence": "2", "pickup_type": "3", "drop_off_type": "2" },
52+ { "trip_id": "trip-bus-restricted", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "3", "pickup_type": "1", "drop_off_type": "3" },
53+ { "trip_id": "trip-bus-no-boarding", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "1", "drop_off_type": "0" },
54+ { "trip_id": "trip-bus-no-boarding", "stop_id": "unmapped-stop", "stop_sequence": "2", "pickup_type": "1", "drop_off_type": "1" },
55+ { "trip_id": "trip-bus-no-boarding", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "3", "pickup_type": "1", "drop_off_type": "0" }
56+ ],
57+ "objectCodes": [
58+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-PARTIAL", "object_system": "source", "object_code": "DEST:BUS-PARTIAL" }
59+ ]
60+ },
2061 "sources": {
2162 "agency": [
2263 { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }
@@ -47,12 +88,12 @@
4788 { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" }
4889 ],
4990 "arrets": [
50- { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1" },
51- { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2" },
52- { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO" },
53- { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO" },
54- { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM" },
55- { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR" }
91+ { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1", "arrname": "Gare du Nord" },
92+ { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2", "arrname": "Gare du Nord annexe" },
93+ { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO", "arrname": "Châtelet sud" },
94+ { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO", "arrname": "Châtelet nord" },
95+ { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM", "arrname": "Porte de Versailles" },
96+ { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR", "arrname": "Funiculaire" }
5697 ],
5798 "relations": [
5899 { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" },
@@ -17,6 +17,47 @@
17 { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },17 { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" },
18 { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" }18 { "id": "fixture-invalid-no-transilien", "invalidReason": "No exact TRANSILIEN perimeter pair; five-mode validation rejects the candidate" }
19 ],19 ],
20+ "journeySources": {
21+ "stops": [
22+ { "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_name": "Porte de Paris", "parent_station": "" },
23+ { "stop_id": "IDFM:BUS-MIDDLE", "stop_name": "Arrêt intermédiaire", "parent_station": "" },
24+ { "stop_id": "unmapped-stop", "stop_name": "Arrêt sans rattachement", "parent_station": "" }
25+ ],
26+ "arrets": [
27+ { "arrid": "TERM-BUS-PARTIAL", "zdaid": "44016", "arrname": "Mairie de Stains" },
28+ { "arrid": "7969", "zdaid": "44016", "arrname": "Mairie" }
29+ ],
30+ "trips": [
31+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-partial", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
32+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-express", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
33+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-loop", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
34+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-restricted", "trip_headsign": "Hôpital Européen", "direction_id": "0" },
35+ { "route_id": "IDFM:C100", "trip_id": "trip-bus-no-boarding", "trip_headsign": "Hôpital Européen", "direction_id": "0" }
36+ ],
37+ "stopTimes": [
38+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
39+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:BUS-MIDDLE", "stop_sequence": "2", "pickup_type": "0", "drop_off_type": "0" },
40+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_sequence": "3", "pickup_type": "0", "drop_off_type": "0" },
41+ { "trip_id": "trip-bus-preserved", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "4", "pickup_type": "1", "drop_off_type": "0" },
42+ { "trip_id": "trip-bus-partial", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
43+ { "trip_id": "trip-bus-partial", "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_sequence": "2", "pickup_type": "1", "drop_off_type": "0" },
44+ { "trip_id": "trip-bus-express", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
45+ { "trip_id": "trip-bus-express", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "2", "pickup_type": "1", "drop_off_type": "0" },
46+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "0", "drop_off_type": "0" },
47+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:TERM-BUS-PARTIAL", "stop_sequence": "2", "pickup_type": "0", "drop_off_type": "0" },
48+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "3", "pickup_type": "0", "drop_off_type": "0" },
49+ { "trip_id": "trip-bus-loop", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "4", "pickup_type": "1", "drop_off_type": "0" },
50+ { "trip_id": "trip-bus-restricted", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "2", "drop_off_type": "0" },
51+ { "trip_id": "trip-bus-restricted", "stop_id": "unmapped-stop", "stop_sequence": "2", "pickup_type": "3", "drop_off_type": "2" },
52+ { "trip_id": "trip-bus-restricted", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "3", "pickup_type": "1", "drop_off_type": "3" },
53+ { "trip_id": "trip-bus-no-boarding", "stop_id": "IDFM:ART-BUS-1", "stop_sequence": "1", "pickup_type": "1", "drop_off_type": "0" },
54+ { "trip_id": "trip-bus-no-boarding", "stop_id": "unmapped-stop", "stop_sequence": "2", "pickup_type": "1", "drop_off_type": "1" },
55+ { "trip_id": "trip-bus-no-boarding", "stop_id": "IDFM:TERM-BUS-1", "stop_sequence": "3", "pickup_type": "1", "drop_off_type": "0" }
56+ ],
57+ "objectCodes": [
58+ { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-PARTIAL", "object_system": "source", "object_code": "DEST:BUS-PARTIAL" }
59+ ]
60+ },
20 "sources": {61 "sources": {
21 "agency": [62 "agency": [
22 { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }63 { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }
@@ -47,12 +88,12 @@
47 { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" }88 { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" }
48 ],89 ],
49 "arrets": [90 "arrets": [
50- { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1" },91+ { "arrid": "ARR-BUS-1", "zdaid": "ZDA-BUS-1", "arrname": "Gare du Nord" },
51- { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2" },92+ { "arrid": "ARR-BUS-2", "zdaid": "ZDA-BUS-2", "arrname": "Gare du Nord annexe" },
52- { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO" },93+ { "arrid": "ARR-METRO-EAST", "zdaid": "ZDA-METRO", "arrname": "Châtelet sud" },
53- { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO" },94+ { "arrid": "ARR-METRO-WEST", "zdaid": "ZDA-METRO", "arrname": "Châtelet nord" },
54- { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM" },95+ { "arrid": "ARR-TRAM", "zdaid": "ZDA-TRAM", "arrname": "Porte de Versailles" },
55- { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR" }96+ { "arrid": "ARR-FUNICULAR", "zdaid": "ZDA-FUNICULAR", "arrname": "Funiculaire" }
56 ],97 ],
57 "relations": [98 "relations": [
58 { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" },99 { "zdaid": "ZDA-BUS-1", "arrid": "ARR-BUS-1", "artid": "ART-BUS-1" },
modified fixtures/departures/foundation.json +3 -1
@@ -25,12 +25,14 @@
2525 "aimedAt": 1788000100,
2626 "minutes": 2,
2727 "status": "DELAYED",
28+ "journeyUncertain": false,
2829 "nextIntervalMinutes": 4
2930 },
3031 {
3132 "expectedAt": 1788000360,
3233 "minutes": 6,
33- "status": "ON_TIME"
34+ "status": "ON_TIME",
35+ "journeyUncertain": false
3436 }
3537 ]
3638 }
@@ -25,12 +25,14 @@
25 "aimedAt": 1788000100,25 "aimedAt": 1788000100,
26 "minutes": 2,26 "minutes": 2,
27 "status": "DELAYED",27 "status": "DELAYED",
28+ "journeyUncertain": false,
28 "nextIntervalMinutes": 429 "nextIntervalMinutes": 4
29 },30 },
30 {31 {
31 "expectedAt": 1788000360,32 "expectedAt": 1788000360,
32 "minutes": 6,33 "minutes": 6,
33- "status": "ON_TIME"34+ "status": "ON_TIME",
35+ "journeyUncertain": false
34 }36 }
35 ]37 ]
36 }38 }
modified fixtures/departures/prim/bus.json +2 -2
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C1001" },
1414 "DirectionRef": { "value": "IDFM:1001D" },
15- "DestinationRef": { "value": "IDFM:1001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:32:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:31:00Z",
@@ -26,7 +26,7 @@
2626 "MonitoredVehicleJourney": {
2727 "LineRef": { "value": "IDFM:C1001" },
2828 "DirectionRef": { "value": "IDFM:1001D" },
29- "DestinationRef": { "value": "IDFM:1001DST" },
29+ "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" },
3030 "MonitoredCall": {
3131 "ExpectedDepartureTime": "2026-01-15T08:38:00Z",
3232 "AimedDepartureTime": "2026-01-15T08:37:30Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C1001" },13 "LineRef": { "value": "IDFM:C1001" },
14 "DirectionRef": { "value": "IDFM:1001D" },14 "DirectionRef": { "value": "IDFM:1001D" },
15- "DestinationRef": { "value": "IDFM:1001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:32:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:32:00Z",
18 "AimedDepartureTime": "2026-01-15T08:31:00Z",18 "AimedDepartureTime": "2026-01-15T08:31:00Z",
@@ -26,7 +26,7 @@
26 "MonitoredVehicleJourney": {26 "MonitoredVehicleJourney": {
27 "LineRef": { "value": "IDFM:C1001" },27 "LineRef": { "value": "IDFM:C1001" },
28 "DirectionRef": { "value": "IDFM:1001D" },28 "DirectionRef": { "value": "IDFM:1001D" },
29- "DestinationRef": { "value": "IDFM:1001DST" },29+ "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" },
30 "MonitoredCall": {30 "MonitoredCall": {
31 "ExpectedDepartureTime": "2026-01-15T08:38:00Z",31 "ExpectedDepartureTime": "2026-01-15T08:38:00Z",
32 "AimedDepartureTime": "2026-01-15T08:37:30Z",32 "AimedDepartureTime": "2026-01-15T08:37:30Z",
modified fixtures/departures/prim/cancelled.json +3 -3
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C3001" },
1414 "DirectionRef": { "value": "IDFM:3001D" },
15- "DestinationRef": { "value": "IDFM:3001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:32:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:31:30Z",
@@ -26,7 +26,7 @@
2626 "MonitoredVehicleJourney": {
2727 "LineRef": { "value": "IDFM:C3001" },
2828 "DirectionRef": { "value": "IDFM:3001D" },
29- "DestinationRef": { "value": "IDFM:3001DST" },
29+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
3030 "MonitoredCall": {
3131 "ExpectedDepartureTime": "2026-01-15T08:35:00Z",
3232 "AimedDepartureTime": "2026-01-15T08:34:30Z",
@@ -40,7 +40,7 @@
4040 "MonitoredVehicleJourney": {
4141 "LineRef": { "value": "IDFM:C3001" },
4242 "DirectionRef": { "value": "IDFM:3001D" },
43- "DestinationRef": { "value": "IDFM:3001DST" },
43+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
4444 "MonitoredCall": {
4545 "ExpectedDepartureTime": "2026-01-15T08:40:00Z",
4646 "AimedDepartureTime": "2026-01-15T08:39:30Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C3001" },13 "LineRef": { "value": "IDFM:C3001" },
14 "DirectionRef": { "value": "IDFM:3001D" },14 "DirectionRef": { "value": "IDFM:3001D" },
15- "DestinationRef": { "value": "IDFM:3001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:32:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:32:00Z",
18 "AimedDepartureTime": "2026-01-15T08:31:30Z",18 "AimedDepartureTime": "2026-01-15T08:31:30Z",
@@ -26,7 +26,7 @@
26 "MonitoredVehicleJourney": {26 "MonitoredVehicleJourney": {
27 "LineRef": { "value": "IDFM:C3001" },27 "LineRef": { "value": "IDFM:C3001" },
28 "DirectionRef": { "value": "IDFM:3001D" },28 "DirectionRef": { "value": "IDFM:3001D" },
29- "DestinationRef": { "value": "IDFM:3001DST" },29+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
30 "MonitoredCall": {30 "MonitoredCall": {
31 "ExpectedDepartureTime": "2026-01-15T08:35:00Z",31 "ExpectedDepartureTime": "2026-01-15T08:35:00Z",
32 "AimedDepartureTime": "2026-01-15T08:34:30Z",32 "AimedDepartureTime": "2026-01-15T08:34:30Z",
@@ -40,7 +40,7 @@
40 "MonitoredVehicleJourney": {40 "MonitoredVehicleJourney": {
41 "LineRef": { "value": "IDFM:C3001" },41 "LineRef": { "value": "IDFM:C3001" },
42 "DirectionRef": { "value": "IDFM:3001D" },42 "DirectionRef": { "value": "IDFM:3001D" },
43- "DestinationRef": { "value": "IDFM:3001DST" },43+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
44 "MonitoredCall": {44 "MonitoredCall": {
45 "ExpectedDepartureTime": "2026-01-15T08:40:00Z",45 "ExpectedDepartureTime": "2026-01-15T08:40:00Z",
46 "AimedDepartureTime": "2026-01-15T08:39:30Z",46 "AimedDepartureTime": "2026-01-15T08:39:30Z",
modified fixtures/departures/prim/delayed.json +1 -1
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C2001" },
1414 "DirectionRef": { "value": "IDFM:2001D" },
15- "DestinationRef": { "value": "IDFM:2001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:37:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:33:00Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C2001" },13 "LineRef": { "value": "IDFM:C2001" },
14 "DirectionRef": { "value": "IDFM:2001D" },14 "DirectionRef": { "value": "IDFM:2001D" },
15- "DestinationRef": { "value": "IDFM:2001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:37:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:37:00Z",
18 "AimedDepartureTime": "2026-01-15T08:33:00Z",18 "AimedDepartureTime": "2026-01-15T08:33:00Z",
modified fixtures/departures/prim/malformed.json +1 -1
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C2001" },
1414 "DirectionRef": { "value": "IDFM:2001D" },
15- "DestinationRef": { "value": "IDFM:2001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" },
1616 "MonitoredCall": {
1717 "DepartureStatus": "ontime"
1818 }
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C2001" },13 "LineRef": { "value": "IDFM:C2001" },
14 "DirectionRef": { "value": "IDFM:2001D" },14 "DirectionRef": { "value": "IDFM:2001D" },
15- "DestinationRef": { "value": "IDFM:2001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "DepartureStatus": "ontime"17 "DepartureStatus": "ontime"
18 }18 }
modified fixtures/departures/prim/metro.json +1 -1
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C2001" },
1414 "DirectionRef": { "value": "IDFM:2001D" },
15- "DestinationRef": { "value": "IDFM:2001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:34:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:33:00Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C2001" },13 "LineRef": { "value": "IDFM:C2001" },
14 "DirectionRef": { "value": "IDFM:2001D" },14 "DirectionRef": { "value": "IDFM:2001D" },
15- "DestinationRef": { "value": "IDFM:2001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:34:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:34:00Z",
18 "AimedDepartureTime": "2026-01-15T08:33:00Z",18 "AimedDepartureTime": "2026-01-15T08:33:00Z",
modified fixtures/departures/prim/mixed.json +3 -3
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C4001" },
1414 "DirectionRef": { "value": "IDFM:4001D" },
15- "DestinationRef": { "value": "IDFM:4001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:33:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:32:30Z",
@@ -26,7 +26,7 @@
2626 "MonitoredVehicleJourney": {
2727 "LineRef": { "value": "IDFM:C4001" },
2828 "DirectionRef": { "value": "IDFM:4001D" },
29- "DestinationRef": { "value": "IDFM:4001DST" },
29+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
3030 "MonitoredCall": {
3131 "AimedDepartureTime": "2026-01-15T08:41:00Z"
3232 }
@@ -38,7 +38,7 @@
3838 "MonitoredVehicleJourney": {
3939 "LineRef": { "value": "IDFM:C4001" },
4040 "DirectionRef": { "value": "IDFM:4001D" },
41- "DestinationRef": { "value": "IDFM:4001DST" },
41+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
4242 "MonitoredCall": {
4343 "ExpectedDepartureTime": "2026-01-15T08:49:00Z",
4444 "AimedDepartureTime": "2026-01-15T08:48:00Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C4001" },13 "LineRef": { "value": "IDFM:C4001" },
14 "DirectionRef": { "value": "IDFM:4001D" },14 "DirectionRef": { "value": "IDFM:4001D" },
15- "DestinationRef": { "value": "IDFM:4001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:33:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:33:00Z",
18 "AimedDepartureTime": "2026-01-15T08:32:30Z",18 "AimedDepartureTime": "2026-01-15T08:32:30Z",
@@ -26,7 +26,7 @@
26 "MonitoredVehicleJourney": {26 "MonitoredVehicleJourney": {
27 "LineRef": { "value": "IDFM:C4001" },27 "LineRef": { "value": "IDFM:C4001" },
28 "DirectionRef": { "value": "IDFM:4001D" },28 "DirectionRef": { "value": "IDFM:4001D" },
29- "DestinationRef": { "value": "IDFM:4001DST" },29+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
30 "MonitoredCall": {30 "MonitoredCall": {
31 "AimedDepartureTime": "2026-01-15T08:41:00Z"31 "AimedDepartureTime": "2026-01-15T08:41:00Z"
32 }32 }
@@ -38,7 +38,7 @@
38 "MonitoredVehicleJourney": {38 "MonitoredVehicleJourney": {
39 "LineRef": { "value": "IDFM:C4001" },39 "LineRef": { "value": "IDFM:C4001" },
40 "DirectionRef": { "value": "IDFM:4001D" },40 "DirectionRef": { "value": "IDFM:4001D" },
41- "DestinationRef": { "value": "IDFM:4001DST" },41+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
42 "MonitoredCall": {42 "MonitoredCall": {
43 "ExpectedDepartureTime": "2026-01-15T08:49:00Z",43 "ExpectedDepartureTime": "2026-01-15T08:49:00Z",
44 "AimedDepartureTime": "2026-01-15T08:48:00Z",44 "AimedDepartureTime": "2026-01-15T08:48:00Z",
modified fixtures/departures/prim/partial.json +3 -3
@@ -10,7 +10,7 @@
1010 "MonitoredVehicleJourney": {
1111 "LineRef": { "value": "IDFM:C1001" },
1212 "DirectionRef": { "value": "IDFM:1099D" },
13- "DestinationRef": { "value": "IDFM:1099DST" },
13+ "DestinationRef": { "value": "STIF:StopPoint:Q:1099DST:" },
1414 "MonitoredCall": {
1515 "ExpectedDepartureTime": "2026-01-15T08:31:00Z",
1616 "AimedDepartureTime": "2026-01-15T08:31:00Z",
@@ -24,7 +24,7 @@
2424 "MonitoredVehicleJourney": {
2525 "LineRef": { "value": "IDFM:C1001" },
2626 "DirectionRef": { "value": "IDFM:1001D" },
27- "DestinationRef": { "value": "IDFM:1001DST" },
27+ "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" },
2828 "MonitoredCall": {
2929 "ExpectedDepartureTime": "2026-01-15T08:34:00Z",
3030 "AimedDepartureTime": "2026-01-15T08:33:00Z",
@@ -42,7 +42,7 @@
4242 "MonitoredVehicleJourney": {
4343 "LineRef": { "value": "IDFM:C1099" },
4444 "DirectionRef": { "value": "IDFM:1099D" },
45- "DestinationRef": { "value": "IDFM:1099DST" },
45+ "DestinationRef": { "value": "STIF:StopPoint:Q:1099DST:" },
4646 "MonitoredCall": {
4747 "ExpectedDepartureTime": "2026-01-15T08:36:00Z",
4848 "AimedDepartureTime": "2026-01-15T08:36:00Z",
@@ -10,7 +10,7 @@
10 "MonitoredVehicleJourney": {10 "MonitoredVehicleJourney": {
11 "LineRef": { "value": "IDFM:C1001" },11 "LineRef": { "value": "IDFM:C1001" },
12 "DirectionRef": { "value": "IDFM:1099D" },12 "DirectionRef": { "value": "IDFM:1099D" },
13- "DestinationRef": { "value": "IDFM:1099DST" },13+ "DestinationRef": { "value": "STIF:StopPoint:Q:1099DST:" },
14 "MonitoredCall": {14 "MonitoredCall": {
15 "ExpectedDepartureTime": "2026-01-15T08:31:00Z",15 "ExpectedDepartureTime": "2026-01-15T08:31:00Z",
16 "AimedDepartureTime": "2026-01-15T08:31:00Z",16 "AimedDepartureTime": "2026-01-15T08:31:00Z",
@@ -24,7 +24,7 @@
24 "MonitoredVehicleJourney": {24 "MonitoredVehicleJourney": {
25 "LineRef": { "value": "IDFM:C1001" },25 "LineRef": { "value": "IDFM:C1001" },
26 "DirectionRef": { "value": "IDFM:1001D" },26 "DirectionRef": { "value": "IDFM:1001D" },
27- "DestinationRef": { "value": "IDFM:1001DST" },27+ "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" },
28 "MonitoredCall": {28 "MonitoredCall": {
29 "ExpectedDepartureTime": "2026-01-15T08:34:00Z",29 "ExpectedDepartureTime": "2026-01-15T08:34:00Z",
30 "AimedDepartureTime": "2026-01-15T08:33:00Z",30 "AimedDepartureTime": "2026-01-15T08:33:00Z",
@@ -42,7 +42,7 @@
42 "MonitoredVehicleJourney": {42 "MonitoredVehicleJourney": {
43 "LineRef": { "value": "IDFM:C1099" },43 "LineRef": { "value": "IDFM:C1099" },
44 "DirectionRef": { "value": "IDFM:1099D" },44 "DirectionRef": { "value": "IDFM:1099D" },
45- "DestinationRef": { "value": "IDFM:1099DST" },45+ "DestinationRef": { "value": "STIF:StopPoint:Q:1099DST:" },
46 "MonitoredCall": {46 "MonitoredCall": {
47 "ExpectedDepartureTime": "2026-01-15T08:36:00Z",47 "ExpectedDepartureTime": "2026-01-15T08:36:00Z",
48 "AimedDepartureTime": "2026-01-15T08:36:00Z",48 "AimedDepartureTime": "2026-01-15T08:36:00Z",
modified fixtures/departures/prim/rer.json +2 -2
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C4001" },
1414 "DirectionRef": { "value": "IDFM:4001D" },
15- "DestinationRef": { "value": "IDFM:4001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:33:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:32:30Z",
@@ -26,7 +26,7 @@
2626 "MonitoredVehicleJourney": {
2727 "LineRef": { "value": "IDFM:C4001" },
2828 "DirectionRef": { "value": "IDFM:4001D" },
29- "DestinationRef": { "value": "IDFM:4001DST" },
29+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
3030 "MonitoredCall": {
3131 "ExpectedDepartureTime": "2026-01-15T08:47:00Z",
3232 "AimedDepartureTime": "2026-01-15T08:45:00Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C4001" },13 "LineRef": { "value": "IDFM:C4001" },
14 "DirectionRef": { "value": "IDFM:4001D" },14 "DirectionRef": { "value": "IDFM:4001D" },
15- "DestinationRef": { "value": "IDFM:4001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:33:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:33:00Z",
18 "AimedDepartureTime": "2026-01-15T08:32:30Z",18 "AimedDepartureTime": "2026-01-15T08:32:30Z",
@@ -26,7 +26,7 @@
26 "MonitoredVehicleJourney": {26 "MonitoredVehicleJourney": {
27 "LineRef": { "value": "IDFM:C4001" },27 "LineRef": { "value": "IDFM:C4001" },
28 "DirectionRef": { "value": "IDFM:4001D" },28 "DirectionRef": { "value": "IDFM:4001D" },
29- "DestinationRef": { "value": "IDFM:4001DST" },29+ "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" },
30 "MonitoredCall": {30 "MonitoredCall": {
31 "ExpectedDepartureTime": "2026-01-15T08:47:00Z",31 "ExpectedDepartureTime": "2026-01-15T08:47:00Z",
32 "AimedDepartureTime": "2026-01-15T08:45:00Z",32 "AimedDepartureTime": "2026-01-15T08:45:00Z",
modified fixtures/departures/prim/scheduled-only.json +2 -2
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C5001" },
1414 "DirectionRef": { "value": "IDFM:5001D" },
15- "DestinationRef": { "value": "IDFM:5001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
1616 "MonitoredCall": {
1717 "AimedDepartureTime": "2026-01-15T08:44:00Z"
1818 }
@@ -24,7 +24,7 @@
2424 "MonitoredVehicleJourney": {
2525 "LineRef": { "value": "IDFM:C5001" },
2626 "DirectionRef": { "value": "IDFM:5001D" },
27- "DestinationRef": { "value": "IDFM:5001DST" },
27+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
2828 "MonitoredCall": {
2929 "AimedDepartureTime": "2026-01-15T08:58:00Z"
3030 }
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C5001" },13 "LineRef": { "value": "IDFM:C5001" },
14 "DirectionRef": { "value": "IDFM:5001D" },14 "DirectionRef": { "value": "IDFM:5001D" },
15- "DestinationRef": { "value": "IDFM:5001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "AimedDepartureTime": "2026-01-15T08:44:00Z"17 "AimedDepartureTime": "2026-01-15T08:44:00Z"
18 }18 }
@@ -24,7 +24,7 @@
24 "MonitoredVehicleJourney": {24 "MonitoredVehicleJourney": {
25 "LineRef": { "value": "IDFM:C5001" },25 "LineRef": { "value": "IDFM:C5001" },
26 "DirectionRef": { "value": "IDFM:5001D" },26 "DirectionRef": { "value": "IDFM:5001D" },
27- "DestinationRef": { "value": "IDFM:5001DST" },27+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
28 "MonitoredCall": {28 "MonitoredCall": {
29 "AimedDepartureTime": "2026-01-15T08:58:00Z"29 "AimedDepartureTime": "2026-01-15T08:58:00Z"
30 }30 }
modified fixtures/departures/prim/tram.json +1 -1
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C3001" },
1414 "DirectionRef": { "value": "IDFM:3001D" },
15- "DestinationRef": { "value": "IDFM:3001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:36:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:35:00Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C3001" },13 "LineRef": { "value": "IDFM:C3001" },
14 "DirectionRef": { "value": "IDFM:3001D" },14 "DirectionRef": { "value": "IDFM:3001D" },
15- "DestinationRef": { "value": "IDFM:3001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:36:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:36:00Z",
18 "AimedDepartureTime": "2026-01-15T08:35:00Z",18 "AimedDepartureTime": "2026-01-15T08:35:00Z",
modified fixtures/departures/prim/transilien.json +2 -2
@@ -12,7 +12,7 @@
1212 "MonitoredVehicleJourney": {
1313 "LineRef": { "value": "IDFM:C5001" },
1414 "DirectionRef": { "value": "IDFM:5001D" },
15- "DestinationRef": { "value": "IDFM:5001DST" },
15+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
1616 "MonitoredCall": {
1717 "ExpectedDepartureTime": "2026-01-15T08:41:00Z",
1818 "AimedDepartureTime": "2026-01-15T08:40:00Z",
@@ -26,7 +26,7 @@
2626 "MonitoredVehicleJourney": {
2727 "LineRef": { "value": "IDFM:C5001" },
2828 "DirectionRef": { "value": "IDFM:5001D" },
29- "DestinationRef": { "value": "IDFM:5001DST" },
29+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
3030 "MonitoredCall": {
3131 "ExpectedDepartureTime": "2026-01-15T08:55:00Z",
3232 "AimedDepartureTime": "2026-01-15T08:54:00Z",
@@ -12,7 +12,7 @@
12 "MonitoredVehicleJourney": {12 "MonitoredVehicleJourney": {
13 "LineRef": { "value": "IDFM:C5001" },13 "LineRef": { "value": "IDFM:C5001" },
14 "DirectionRef": { "value": "IDFM:5001D" },14 "DirectionRef": { "value": "IDFM:5001D" },
15- "DestinationRef": { "value": "IDFM:5001DST" },15+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
16 "MonitoredCall": {16 "MonitoredCall": {
17 "ExpectedDepartureTime": "2026-01-15T08:41:00Z",17 "ExpectedDepartureTime": "2026-01-15T08:41:00Z",
18 "AimedDepartureTime": "2026-01-15T08:40:00Z",18 "AimedDepartureTime": "2026-01-15T08:40:00Z",
@@ -26,7 +26,7 @@
26 "MonitoredVehicleJourney": {26 "MonitoredVehicleJourney": {
27 "LineRef": { "value": "IDFM:C5001" },27 "LineRef": { "value": "IDFM:C5001" },
28 "DirectionRef": { "value": "IDFM:5001D" },28 "DirectionRef": { "value": "IDFM:5001D" },
29- "DestinationRef": { "value": "IDFM:5001DST" },29+ "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" },
30 "MonitoredCall": {30 "MonitoredCall": {
31 "ExpectedDepartureTime": "2026-01-15T08:55:00Z",31 "ExpectedDepartureTime": "2026-01-15T08:55:00Z",
32 "AimedDepartureTime": "2026-01-15T08:54:00Z",32 "AimedDepartureTime": "2026-01-15T08:54:00Z",
modified packages/catalog/src/catalog-import.ts +221 -16
@@ -22,10 +22,12 @@ import {
2222 SCHEMA_VERSION,
2323 TRANSPORT_MODE,
2424 } from "../../contracts/src/index.ts";
25-import type { TransportMode } from "../../contracts/src/index.ts";
25+import type { JourneyPattern, JourneyPlace, JourneyStop, JourneyTerminal, TransportMode } from "../../contracts/src/index.ts";
26+import { validateJourneyCatalog } from "./catalog.ts";
27+import journeyPatterns from "../../companion/src/journey-patterns.js";
2628 import { normalizeCatalogSearchText } from "../../config-page/src/search-text.js";
2729
28-export const CATALOG_VERSION = 3 as const;
30+export const CATALOG_VERSION = 4 as const;
2931 export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000;
3032 export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3;
3133 export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3;
@@ -166,6 +168,7 @@ export interface CatalogBuildResult {
166168
167169 interface CsvSpec {
168170 readonly required: readonly string[];
171+ readonly optional?: readonly string[];
169172 readonly insert: (row: Readonly<Record<string, string>>) => void;
170173 }
171174
@@ -289,13 +292,13 @@ function fsyncDirectory(path: string): void {
289292 }
290293 }
291294
292-function canonicalTuple(namespace: "place" | "service", fields: readonly string[]): string {
295+function canonicalTuple(namespace: "place" | "service" | "journey-stop", fields: readonly string[]): string {
293296 let tuple = `lapin-fute:${namespace}:v1`;
294297 for (const field of fields) tuple += `|${Buffer.byteLength(field, "utf8")}:${field}`;
295298 return tuple;
296299 }
297300
298-function opaqueId(prefix: "plc_" | "svc_", tuple: string): string {
301+function opaqueId(prefix: "plc_" | "svc_" | "grp_" | "pat_" | "term_", tuple: string): string {
299302 return prefix + createHash("sha256").update(tuple, "utf8").digest("base64url");
300303 }
301304
@@ -507,6 +510,25 @@ function createSchema(database: DatabaseSync): void {
507510 destination_ref TEXT NOT NULL,
508511 canonical_tuple TEXT NOT NULL UNIQUE
509512 ) STRICT;
513+ CREATE TABLE journey_groups (
514+ group_id TEXT PRIMARY KEY,
515+ line_mode TEXT NOT NULL,
516+ line_ref TEXT NOT NULL,
517+ UNIQUE(line_mode,line_ref)
518+ ) STRICT;
519+ CREATE TABLE journey_rows (
520+ group_id TEXT NOT NULL REFERENCES journey_groups(group_id),
521+ row_kind TEXT NOT NULL CHECK(row_kind IN ('place','terminal','pattern')),
522+ row_id TEXT NOT NULL,
523+ row_json TEXT NOT NULL,
524+ PRIMARY KEY(group_id,row_kind,row_id)
525+ ) STRICT;
526+ CREATE TABLE service_journeys (
527+ service_id TEXT PRIMARY KEY REFERENCES services(service_id),
528+ group_id TEXT NOT NULL REFERENCES journey_groups(group_id),
529+ terminal_place_id TEXT
530+ ) STRICT;
531+ CREATE INDEX service_journeys_group ON service_journeys(group_id);
510532 CREATE VIRTUAL TABLE place_search USING fts5(
511533 search_text,
512534 place_id UNINDEXED,
@@ -529,6 +551,7 @@ function createSchema(database: DatabaseSync): void {
529551 stop_id TEXT NOT NULL,
530552 stop_sequence INTEGER NOT NULL,
531553 pickup_type INTEGER NOT NULL,
554+ drop_off_type INTEGER NOT NULL,
532555 PRIMARY KEY (trip_id, stop_sequence)
533556 ) STRICT;
534557 CREATE TABLE stage_stops (
@@ -554,7 +577,8 @@ function createSchema(database: DatabaseSync): void {
554577 ) STRICT;
555578 CREATE TABLE stage_arrets (
556579 arrid TEXT PRIMARY KEY,
557- zdaid TEXT NOT NULL
580+ zdaid TEXT NOT NULL,
581+ arrname TEXT NOT NULL
558582 ) STRICT;
559583 CREATE TABLE stage_zones (
560584 zdaid TEXT PRIMARY KEY,
@@ -587,12 +611,12 @@ function createSchema(database: DatabaseSync): void {
587611 function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>> {
588612 const route = database.prepare("INSERT INTO stage_routes VALUES (?, ?, ?)");
589613 const trip = database.prepare("INSERT INTO stage_trips VALUES (?, ?, ?, ?)");
590- const stopTime = database.prepare("INSERT INTO stage_stop_times VALUES (?, ?, ?, ?)");
614+ const stopTime = database.prepare("INSERT INTO stage_stop_times VALUES (?, ?, ?, ?, ?)");
591615 const stop = database.prepare("INSERT INTO stage_stops VALUES (?, ?, ?)");
592616 const agency = database.prepare("INSERT OR IGNORE INTO stage_agency VALUES (?)");
593617 const code = database.prepare("INSERT OR IGNORE INTO stage_codes VALUES (?, ?, ?, ?)");
594618 const perimeter = database.prepare("INSERT OR IGNORE INTO stage_perimeter VALUES (?, ?, ?)");
595- const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)");
619+ const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?, ?)");
596620 const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)");
597621 const relation = database.prepare("INSERT INTO stage_relations VALUES (?, ?, ?)");
598622 const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
@@ -609,10 +633,12 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
609633 },
610634 stopTimes: {
611635 required: ["trip_id", "stop_id", "stop_sequence", "pickup_type"],
636+ optional: ["drop_off_type"],
612637 insert: (row) => {
613- const pickupType = row.pickup_type === "" ? 0 : integer(row.pickup_type, "stop_times.pickup_type");
614- if (pickupType < 0 || pickupType > 3) fail("INVALID_SOURCE", "stop_times.pickup_type is outside GTFS range");
615- stopTime.run(nonEmpty(row.trip_id, "stop_times.trip_id"), nonEmpty(row.stop_id, "stop_times.stop_id"), integer(row.stop_sequence, "stop_times.stop_sequence"), pickupType);
638+ const pickupType = row.pickup_type === undefined || row.pickup_type === "" ? 0 : integer(row.pickup_type, "stop_times.pickup_type");
639+ const dropOffType = row.drop_off_type === undefined || row.drop_off_type === "" ? 0 : integer(row.drop_off_type, "stop_times.drop_off_type");
640+ if (pickupType < 0 || pickupType > 3 || dropOffType < 0 || dropOffType > 3) fail("INVALID_SOURCE", "stop_times boarding restriction is outside GTFS range");
641+ stopTime.run(nonEmpty(row.trip_id, "stop_times.trip_id"), nonEmpty(row.stop_id, "stop_times.stop_id"), integer(row.stop_sequence, "stop_times.stop_sequence"), pickupType, dropOffType);
616642 },
617643 },
618644 stops: {
@@ -637,8 +663,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
637663 insert: (row) => perimeter.run(nonEmpty(row.line, "perimeter.line"), canonicalLineId(row.line, "perimeter.line"), nonEmpty(row.ns2_stoppointref, "perimeter.ns2_stoppointref")),
638664 },
639665 arrets: {
640- required: ["arrid", "zdaid"],
641- insert: (row) => arret.run(nonEmpty(row.arrid, "arrets.arrid"), nonEmpty(row.zdaid, "arrets.zdaid")),
666+ required: ["arrid", "zdaid", "arrname"],
667+ insert: (row) => arret.run(nonEmpty(row.arrid, "arrets.arrid"), nonEmpty(row.zdaid, "arrets.zdaid"), optionalText(row.arrname)),
642668 },
643669 zones: {
644670 required: ["zdaid", "zdaname", "zdatown"],
@@ -646,7 +672,17 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
646672 },
647673 relations: {
648674 required: ["zdaid", "arrid", "artid"],
649- insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), optionalText(row.artid) || null),
675+ optional: ["zdcid"],
676+ insert: (row) => {
677+ const arrid = optionalText(row.arrid);
678+ const artid = optionalText(row.artid);
679+ if (arrid === "" && artid === "") {
680+ // The hierarchy can end at a ZdA or ZdC without any stop to project.
681+ if (optionalText(row.zdaid) === "") nonEmpty(row.zdcid, "relations.zdcid");
682+ return;
683+ }
684+ relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(arrid, "relations.arrid"), artid || null);
685+ },
650686 },
651687 lines: {
652688 required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"],
@@ -678,12 +714,15 @@ function runBatched(database: DatabaseSync, rows: Iterable<Readonly<Record<strin
678714 }
679715 }
680716
681-function stringRecord(record: CsvRecord, required: readonly string[]): Readonly<Record<string, string>> {
717+function stringRecord(record: CsvRecord, spec: CsvSpec): Readonly<Record<string, string>> {
682718 const result: Record<string, string> = {};
683- for (const key of required) {
719+ for (const key of spec.required) {
684720 if (!Object.hasOwn(record, key)) fail("MISSING_COLUMN", `record is missing ${key}`);
685721 result[key] = String(record[key] ?? "");
686722 }
723+ for (const key of spec.optional ?? []) {
724+ if (Object.hasOwn(record, key)) result[key] = String(record[key] ?? "");
725+ }
687726 return result;
688727 }
689728
@@ -691,7 +730,7 @@ function loadRecordSources(database: DatabaseSync, sources: CatalogRecordSourceS
691730 const specs = prepareSpecs(database);
692731 for (const key of Object.keys(specs) as (keyof CatalogRecordSourceSet)[]) {
693732 const spec = specs[key];
694- const rows = sources[key].map((row) => stringRecord(row, spec.required));
733+ const rows = sources[key].map((row) => stringRecord(row, spec));
695734 runBatched(database, rows, spec);
696735 }
697736 }
@@ -786,6 +825,10 @@ async function loadCsvStream(
786825 if (index === -1) fail("MISSING_COLUMN", `CSV is missing required column ${required}`);
787826 mapped[required] = index;
788827 }
828+ for (const optional of spec.optional ?? []) {
829+ const index = header.indexOf(optional);
830+ if (index !== -1) mapped[optional] = index;
831+ }
789832 indexes = mapped;
790833 continue;
791834 }
@@ -1027,6 +1070,9 @@ function deriveCandidates(database: DatabaseSync): void {
10271070 ELSE stop.stop_id
10281071 END;
10291072
1073+ CREATE INDEX stage_stop_zda_place ON stage_stop_zda(zdaid);
1074+ CREATE INDEX stage_stop_zda_rail_place ON stage_stop_zda(rail_zdaid);
1075+
10301076 CREATE TABLE stage_terminals AS
10311077 SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name
10321078 FROM stage_stop_times AS stop_time
@@ -1040,6 +1086,17 @@ function deriveCandidates(database: DatabaseSync): void {
10401086 AND terminal.stop_sequence = stop_time.stop_sequence;
10411087 CREATE UNIQUE INDEX stage_terminals_trip ON stage_terminals(trip_id);
10421088
1089+ -- Retain every source occurrence before the service projection loses order.
1090+ CREATE TABLE stage_journey_stops AS
1091+ SELECT line_mode.mode, line_mode.id_line, trip.trip_id,
1092+ stop_time.stop_sequence, stop_time.stop_id, stop_time.pickup_type, stop_time.drop_off_type
1093+ FROM stage_trips AS trip
1094+ JOIN stage_routes AS route
1095+ ON route.route_id = CASE WHEN trip.route_id LIKE 'IDFM:%' THEN substr(trip.route_id, 6) ELSE trip.route_id END
1096+ JOIN stage_line_modes AS line_mode ON line_mode.id_line = route.route_id
1097+ JOIN stage_stop_times AS stop_time ON stop_time.trip_id = trip.trip_id;
1098+ CREATE INDEX stage_journey_line ON stage_journey_stops(mode, id_line, trip_id, stop_sequence);
1099+
10431100 CREATE TABLE stage_gtfs_services AS
10441101 SELECT DISTINCT trip.route_id, trip.trip_headsign, trip.direction_id,
10451102 current_stop_time.stop_id, terminal.stop_id AS terminal_stop_id,
@@ -1098,6 +1155,9 @@ function deriveCandidates(database: DatabaseSync): void {
10981155 AND trip.terminal_stop_id LIKE 'IDFM:%' AND trip.terminal_stop_id NOT LIKE 'IDFM:monomodalStopPlace:%')
10991156 );
11001157
1158+ CREATE INDEX stage_routing_terminal_tuple ON stage_routing_candidates
1159+ (mode, id_line, monitoring_ref, direction_id, destination_ref, source_terminal_code);
1160+
11011161 -- Detect conflicts before perimeter or attachment exclusions can hide one side.
11021162 CREATE TABLE stage_routing_collisions AS
11031163 SELECT monitoring_ref, id_line, destination_ref
@@ -1150,6 +1210,7 @@ function deriveCandidates(database: DatabaseSync): void {
11501210 )
11511211 GROUP BY candidate.mode, candidate.zdaid, candidate.monitoring_ref, candidate.id_line,
11521212 perimeter.line, candidate.direction_id, candidate.destination_ref, candidate.source_terminal_code;
1213+ CREATE INDEX stage_candidates_journey_group ON stage_candidates(mode, line_ref);
11531214 `);
11541215 }
11551216
@@ -1252,6 +1313,147 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
12521313 return placeResolution;
12531314 }
12541315
1316+interface JourneySourceStop {
1317+ stop_id: string;
1318+ stop_name: string;
1319+ parent_station: string;
1320+ zdaid: string | null;
1321+ rail_zdaid: string;
1322+}
1323+
1324+function journeyStopIdentity(mode: TransportMode, stop: JourneySourceStop): { placeId: string; stopRef: string | null } {
1325+ const rail = mode === "RER" || mode === "TRANSILIEN";
1326+ const railProven = stop.stop_id.startsWith("IDFM:monomodalStopPlace:")
1327+ || stop.parent_station.startsWith("IDFM:monomodalStopPlace:");
1328+ const zda = rail ? (railProven ? stop.rail_zdaid : null) : stop.zdaid;
1329+ const placeId = zda === null
1330+ ? opaqueId("plc_", canonicalTuple("journey-stop", [mode, stop.stop_id]))
1331+ : createPlaceIdentity(mode, zda).placeId;
1332+ const suffix = rail ? (railProven ? stop.rail_zdaid : null)
1333+ : /^IDFM:(?!monomodalStopPlace:)[^:\s\x00-\x1f\x7f]+$/u.test(stop.stop_id) ? stop.stop_id.slice(5) : null;
1334+ return { placeId, stopRef: suffix === null ? null : `STIF:${rail ? "StopArea:SP" : "StopPoint:Q"}:${suffix}:` };
1335+}
1336+
1337+function insertJourneys(database: DatabaseSync): void {
1338+ const sourceStop = database.prepare(`
1339+ SELECT stop.*, projection.zdaid, projection.rail_zdaid
1340+ FROM stage_stops AS stop JOIN stage_stop_zda AS projection USING(stop_id)
1341+ WHERE stop.stop_id = ?
1342+ `);
1343+ const equivalentStops = database.prepare(`
1344+ SELECT stop.*, projection.zdaid, projection.rail_zdaid
1345+ FROM stage_stops AS stop JOIN stage_stop_zda AS projection USING(stop_id)
1346+ WHERE (? = 0 AND projection.zdaid = ?) OR (? = 1 AND projection.rail_zdaid = ?)
1347+ `);
1348+ const equivalentArrets = database.prepare("SELECT arrid, arrname FROM stage_arrets WHERE zdaid = ?");
1349+ const insertGroup = database.prepare("INSERT INTO journey_groups VALUES (?, ?, ?)");
1350+ const insertRow = database.prepare("INSERT INTO journey_rows VALUES (?, ?, ?, ?)");
1351+ const insertServiceJourney = database.prepare("INSERT INTO service_journeys VALUES (?, ?, ?)");
1352+ const terminalSources = database.prepare(`
1353+ SELECT DISTINCT terminal_stop_id FROM stage_routing_candidates
1354+ WHERE mode = ? AND id_line = ? AND monitoring_ref = ? AND direction_id = ?
1355+ AND destination_ref = ? AND source_terminal_code = ?
1356+ `);
1357+ const groups = database.prepare("SELECT DISTINCT mode, id_line, line_ref FROM stage_candidates ORDER BY mode, line_ref");
1358+ for (const group of groups.iterate() as Iterable<QueryRow>) {
1359+ const mode = transportMode(group.mode);
1360+ const lineRef = String(group.line_ref);
1361+ const groupId = opaqueId("grp_", JSON.stringify([mode, lineRef]));
1362+ const places = new Map<string, JourneyPlace>();
1363+ const terminals = new Map<string, JourneyTerminal>();
1364+ const patterns = new Map<string, JourneyPattern>();
1365+ let patternBytes = 0;
1366+ const stopCache = new Map<string, JourneySourceStop>();
1367+ const resolveStop = (stopId: string): JourneySourceStop => {
1368+ let stop = stopCache.get(stopId);
1369+ if (stop === undefined) {
1370+ stop = sourceStop.get(stopId) as unknown as JourneySourceStop | undefined;
1371+ if (stop === undefined) fail("CATALOG_JOURNEY", `missing GTFS stop ${stopId}`);
1372+ stopCache.set(stopId, stop);
1373+ }
1374+ return stop;
1375+ };
1376+ const addPlace = (stop: JourneySourceStop): { placeId: string; stopRef: string | null } => {
1377+ const identity = journeyStopIdentity(mode, stop);
1378+ const previous = places.get(identity.placeId);
1379+ if (previous === undefined || stop.stop_name < previous.label) {
1380+ places.set(identity.placeId, { kind: "place", placeId: identity.placeId, label: stop.stop_name });
1381+ if (places.size > journeyPatterns.JOURNEY_LIMITS.places) fail("CATALOG_JOURNEY", `too many places in ${groupId}`);
1382+ }
1383+ return identity;
1384+ };
1385+ let tripId: string | undefined;
1386+ let stops: JourneyStop[] = [];
1387+ let terminalStop: JourneySourceStop | undefined;
1388+ const finishTrip = (): void => {
1389+ if (terminalStop === undefined) return;
1390+ const terminalId = opaqueId("term_", JSON.stringify([mode, terminalStop.stop_id]));
1391+ if (!terminals.has(terminalId)) {
1392+ const identity = addPlace(terminalStop);
1393+ const refs = new Set<string>(identity.stopRef === null ? [] : [identity.stopRef]);
1394+ const labels = new Set<string>([terminalStop.stop_name]);
1395+ const rail = mode === "RER" || mode === "TRANSILIEN";
1396+ const zda = rail ? terminalStop.rail_zdaid : terminalStop.zdaid;
1397+ if (zda !== null && (!rail || identity.stopRef !== null)) {
1398+ for (const equivalent of equivalentStops.iterate(Number(rail), zda, Number(rail), zda) as unknown as Iterable<JourneySourceStop>) {
1399+ const other = journeyStopIdentity(mode, equivalent);
1400+ if (other.placeId !== identity.placeId) continue;
1401+ if (other.stopRef !== null) refs.add(other.stopRef);
1402+ labels.add(equivalent.stop_name);
1403+ addPlace(equivalent);
1404+ }
1405+ for (const equivalent of equivalentArrets.iterate(zda) as Iterable<QueryRow>) {
1406+ if (!rail) refs.add(`STIF:StopPoint:Q:${String(equivalent.arrid)}:`);
1407+ if (String(equivalent.arrname) !== "") labels.add(String(equivalent.arrname));
1408+ }
1409+ if (refs.size > journeyPatterns.JOURNEY_LIMITS.terminalRefs || labels.size > journeyPatterns.JOURNEY_LIMITS.terminalLabels) {
1410+ fail("CATALOG_JOURNEY", `too many terminal aliases in ${groupId}`);
1411+ }
1412+ }
1413+ terminals.set(terminalId, { kind: "terminal", terminalId, terminalPlaceId: identity.placeId, refs: [...refs].sort(), labels: [...labels].sort() });
1414+ if (terminals.size > journeyPatterns.JOURNEY_LIMITS.terminals) fail("CATALOG_JOURNEY", `too many terminals in ${groupId}`);
1415+ }
1416+ const patternId = opaqueId("pat_", JSON.stringify([mode, lineRef, terminalId,
1417+ stops.map((stop) => [stop.stopRef, stop.placeId, stop.pickupType, stop.dropOffType])]));
1418+ const pattern: JourneyPattern = { kind: "pattern", patternId, terminalId, stops };
1419+ if (!patterns.has(patternId)) patternBytes += Buffer.byteLength(JSON.stringify(pattern));
1420+ if (patternBytes > journeyPatterns.JOURNEY_LIMITS.groupBytes) fail("CATALOG_JOURNEY", `journey group exceeds byte budget: ${groupId}`);
1421+ patterns.set(patternId, pattern);
1422+ if (patterns.size > journeyPatterns.JOURNEY_LIMITS.patterns) fail("CATALOG_JOURNEY", `too many patterns in ${groupId}`);
1423+ stops = [];
1424+ };
1425+ for (const occurrence of database.prepare(`
1426+ SELECT * FROM stage_journey_stops WHERE mode = ? AND id_line = ?
1427+ ORDER BY trip_id, stop_sequence
1428+ `).iterate(mode, String(group.id_line)) as Iterable<QueryRow>) {
1429+ if (tripId !== occurrence.trip_id) {
1430+ finishTrip();
1431+ tripId = String(occurrence.trip_id);
1432+ }
1433+ terminalStop = resolveStop(String(occurrence.stop_id));
1434+ stops.push({ ...addPlace(terminalStop), pickupType: Number(occurrence.pickup_type) as JourneyStop["pickupType"],
1435+ dropOffType: Number(occurrence.drop_off_type) as JourneyStop["dropOffType"] });
1436+ if (stops.length > journeyPatterns.JOURNEY_LIMITS.stops) fail("CATALOG_JOURNEY", `too many occurrences in ${groupId}`);
1437+ }
1438+ finishTrip();
1439+ insertGroup.run(groupId, mode, lineRef);
1440+ for (const [id, value] of places) insertRow.run(groupId, value.kind, id, JSON.stringify(value));
1441+ for (const [id, value] of terminals) insertRow.run(groupId, value.kind, id, JSON.stringify(value));
1442+ for (const [id, value] of patterns) insertRow.run(groupId, value.kind, id, JSON.stringify(value));
1443+ for (const candidate of database.prepare("SELECT * FROM stage_candidates WHERE mode = ? AND line_ref = ?")
1444+ .iterate(mode, lineRef) as Iterable<QueryRow>) {
1445+ const serviceId = createServiceIdentity({ mode, lineId: String(candidate.id_line), monitoringRef: String(candidate.monitoring_ref),
1446+ directionId: String(candidate.direction_id), destinationRef: String(candidate.source_terminal_code) }).serviceId;
1447+ const terminalPlaces = new Set<string>();
1448+ for (const terminal of terminalSources.iterate(mode, String(candidate.id_line), String(candidate.monitoring_ref),
1449+ String(candidate.direction_id), String(candidate.destination_ref), String(candidate.source_terminal_code)) as Iterable<QueryRow>) {
1450+ terminalPlaces.add(journeyStopIdentity(mode, resolveStop(String(terminal.terminal_stop_id))).placeId);
1451+ }
1452+ insertServiceJourney.run(serviceId, groupId, terminalPlaces.size === 1 ? [...terminalPlaces][0]! : null);
1453+ }
1454+ }
1455+}
1456+
12551457 function validateByteBounds(database: DatabaseSync): void {
12561458 const invalidPlace = database.prepare(`
12571459 SELECT place_id FROM places
@@ -1309,6 +1511,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult {
13091511 if (integrity.length !== 1 || String(integrity[0]?.integrity_check) !== "ok") fail("CATALOG_INTEGRITY", "SQLite integrity_check failed");
13101512 if ((database.prepare("PRAGMA foreign_key_check").all() as QueryRow[]).length !== 0) fail("CATALOG_FOREIGN_KEY", "SQLite foreign_key_check failed");
13111513 validateByteBounds(database);
1514+ validateJourneyCatalog(database);
13121515
13131516 const missingSearch = database.prepare(`
13141517 SELECT 1 FROM places AS place
@@ -1433,6 +1636,7 @@ function dropStaging(database: DatabaseSync): void {
14331636 DROP TABLE stage_routing_collisions;
14341637 DROP TABLE stage_routing_candidates;
14351638 DROP TABLE stage_gtfs_services;
1639+ DROP TABLE stage_journey_stops;
14361640 DROP TABLE stage_terminals;
14371641 DROP TABLE stage_stop_zda;
14381642 DROP TABLE stage_line_modes;
@@ -1474,6 +1678,7 @@ async function build(
14741678 validateStagedPerimeter(database);
14751679 validateCandidateSemanticTuples(database);
14761680 const placeResolution = insertFinalCatalog(database, sourceRevision, createdAt);
1681+ insertJourneys(database);
14771682 dropStaging(database);
14781683 const result = validateOpenCatalog(database);
14791684 database.close();
@@ -22,10 +22,12 @@ import {
22 SCHEMA_VERSION,22 SCHEMA_VERSION,
23 TRANSPORT_MODE,23 TRANSPORT_MODE,
24 } from "../../contracts/src/index.ts";24 } from "../../contracts/src/index.ts";
25-import type { TransportMode } from "../../contracts/src/index.ts";25+import type { JourneyPattern, JourneyPlace, JourneyStop, JourneyTerminal, TransportMode } from "../../contracts/src/index.ts";
26+import { validateJourneyCatalog } from "./catalog.ts";
27+import journeyPatterns from "../../companion/src/journey-patterns.js";
26 import { normalizeCatalogSearchText } from "../../config-page/src/search-text.js";28 import { normalizeCatalogSearchText } from "../../config-page/src/search-text.js";
27 29
28-export const CATALOG_VERSION = 3 as const;30+export const CATALOG_VERSION = 4 as const;
29 export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000;31 export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000;
30 export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3;32 export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3;
31 export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3;33 export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3;
@@ -166,6 +168,7 @@ export interface CatalogBuildResult {
166 168
167 interface CsvSpec {169 interface CsvSpec {
168 readonly required: readonly string[];170 readonly required: readonly string[];
171+ readonly optional?: readonly string[];
169 readonly insert: (row: Readonly<Record<string, string>>) => void;172 readonly insert: (row: Readonly<Record<string, string>>) => void;
170 }173 }
171 174
@@ -289,13 +292,13 @@ function fsyncDirectory(path: string): void {
289 }292 }
290 }293 }
291 294
292-function canonicalTuple(namespace: "place" | "service", fields: readonly string[]): string {295+function canonicalTuple(namespace: "place" | "service" | "journey-stop", fields: readonly string[]): string {
293 let tuple = `lapin-fute:${namespace}:v1`;296 let tuple = `lapin-fute:${namespace}:v1`;
294 for (const field of fields) tuple += `|${Buffer.byteLength(field, "utf8")}:${field}`;297 for (const field of fields) tuple += `|${Buffer.byteLength(field, "utf8")}:${field}`;
295 return tuple;298 return tuple;
296 }299 }
297 300
298-function opaqueId(prefix: "plc_" | "svc_", tuple: string): string {301+function opaqueId(prefix: "plc_" | "svc_" | "grp_" | "pat_" | "term_", tuple: string): string {
299 return prefix + createHash("sha256").update(tuple, "utf8").digest("base64url");302 return prefix + createHash("sha256").update(tuple, "utf8").digest("base64url");
300 }303 }
301 304
@@ -507,6 +510,25 @@ function createSchema(database: DatabaseSync): void {
507 destination_ref TEXT NOT NULL,510 destination_ref TEXT NOT NULL,
508 canonical_tuple TEXT NOT NULL UNIQUE511 canonical_tuple TEXT NOT NULL UNIQUE
509 ) STRICT;512 ) STRICT;
513+ CREATE TABLE journey_groups (
514+ group_id TEXT PRIMARY KEY,
515+ line_mode TEXT NOT NULL,
516+ line_ref TEXT NOT NULL,
517+ UNIQUE(line_mode,line_ref)
518+ ) STRICT;
519+ CREATE TABLE journey_rows (
520+ group_id TEXT NOT NULL REFERENCES journey_groups(group_id),
521+ row_kind TEXT NOT NULL CHECK(row_kind IN ('place','terminal','pattern')),
522+ row_id TEXT NOT NULL,
523+ row_json TEXT NOT NULL,
524+ PRIMARY KEY(group_id,row_kind,row_id)
525+ ) STRICT;
526+ CREATE TABLE service_journeys (
527+ service_id TEXT PRIMARY KEY REFERENCES services(service_id),
528+ group_id TEXT NOT NULL REFERENCES journey_groups(group_id),
529+ terminal_place_id TEXT
530+ ) STRICT;
531+ CREATE INDEX service_journeys_group ON service_journeys(group_id);
510 CREATE VIRTUAL TABLE place_search USING fts5(532 CREATE VIRTUAL TABLE place_search USING fts5(
511 search_text,533 search_text,
512 place_id UNINDEXED,534 place_id UNINDEXED,
@@ -529,6 +551,7 @@ function createSchema(database: DatabaseSync): void {
529 stop_id TEXT NOT NULL,551 stop_id TEXT NOT NULL,
530 stop_sequence INTEGER NOT NULL,552 stop_sequence INTEGER NOT NULL,
531 pickup_type INTEGER NOT NULL,553 pickup_type INTEGER NOT NULL,
554+ drop_off_type INTEGER NOT NULL,
532 PRIMARY KEY (trip_id, stop_sequence)555 PRIMARY KEY (trip_id, stop_sequence)
533 ) STRICT;556 ) STRICT;
534 CREATE TABLE stage_stops (557 CREATE TABLE stage_stops (
@@ -554,7 +577,8 @@ function createSchema(database: DatabaseSync): void {
554 ) STRICT;577 ) STRICT;
555 CREATE TABLE stage_arrets (578 CREATE TABLE stage_arrets (
556 arrid TEXT PRIMARY KEY,579 arrid TEXT PRIMARY KEY,
557- zdaid TEXT NOT NULL580+ zdaid TEXT NOT NULL,
581+ arrname TEXT NOT NULL
558 ) STRICT;582 ) STRICT;
559 CREATE TABLE stage_zones (583 CREATE TABLE stage_zones (
560 zdaid TEXT PRIMARY KEY,584 zdaid TEXT PRIMARY KEY,
@@ -587,12 +611,12 @@ function createSchema(database: DatabaseSync): void {
587 function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>> {611 function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>> {
588 const route = database.prepare("INSERT INTO stage_routes VALUES (?, ?, ?)");612 const route = database.prepare("INSERT INTO stage_routes VALUES (?, ?, ?)");
589 const trip = database.prepare("INSERT INTO stage_trips VALUES (?, ?, ?, ?)");613 const trip = database.prepare("INSERT INTO stage_trips VALUES (?, ?, ?, ?)");
590- const stopTime = database.prepare("INSERT INTO stage_stop_times VALUES (?, ?, ?, ?)");614+ const stopTime = database.prepare("INSERT INTO stage_stop_times VALUES (?, ?, ?, ?, ?)");
591 const stop = database.prepare("INSERT INTO stage_stops VALUES (?, ?, ?)");615 const stop = database.prepare("INSERT INTO stage_stops VALUES (?, ?, ?)");
592 const agency = database.prepare("INSERT OR IGNORE INTO stage_agency VALUES (?)");616 const agency = database.prepare("INSERT OR IGNORE INTO stage_agency VALUES (?)");
593 const code = database.prepare("INSERT OR IGNORE INTO stage_codes VALUES (?, ?, ?, ?)");617 const code = database.prepare("INSERT OR IGNORE INTO stage_codes VALUES (?, ?, ?, ?)");
594 const perimeter = database.prepare("INSERT OR IGNORE INTO stage_perimeter VALUES (?, ?, ?)");618 const perimeter = database.prepare("INSERT OR IGNORE INTO stage_perimeter VALUES (?, ?, ?)");
595- const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)");619+ const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?, ?)");
596 const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)");620 const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)");
597 const relation = database.prepare("INSERT INTO stage_relations VALUES (?, ?, ?)");621 const relation = database.prepare("INSERT INTO stage_relations VALUES (?, ?, ?)");
598 const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)");622 const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
@@ -609,10 +633,12 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
609 },633 },
610 stopTimes: {634 stopTimes: {
611 required: ["trip_id", "stop_id", "stop_sequence", "pickup_type"],635 required: ["trip_id", "stop_id", "stop_sequence", "pickup_type"],
636+ optional: ["drop_off_type"],
612 insert: (row) => {637 insert: (row) => {
613- const pickupType = row.pickup_type === "" ? 0 : integer(row.pickup_type, "stop_times.pickup_type");638+ const pickupType = row.pickup_type === undefined || row.pickup_type === "" ? 0 : integer(row.pickup_type, "stop_times.pickup_type");
614- if (pickupType < 0 || pickupType > 3) fail("INVALID_SOURCE", "stop_times.pickup_type is outside GTFS range");639+ const dropOffType = row.drop_off_type === undefined || row.drop_off_type === "" ? 0 : integer(row.drop_off_type, "stop_times.drop_off_type");
615- stopTime.run(nonEmpty(row.trip_id, "stop_times.trip_id"), nonEmpty(row.stop_id, "stop_times.stop_id"), integer(row.stop_sequence, "stop_times.stop_sequence"), pickupType);640+ if (pickupType < 0 || pickupType > 3 || dropOffType < 0 || dropOffType > 3) fail("INVALID_SOURCE", "stop_times boarding restriction is outside GTFS range");
641+ stopTime.run(nonEmpty(row.trip_id, "stop_times.trip_id"), nonEmpty(row.stop_id, "stop_times.stop_id"), integer(row.stop_sequence, "stop_times.stop_sequence"), pickupType, dropOffType);
616 },642 },
617 },643 },
618 stops: {644 stops: {
@@ -637,8 +663,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
637 insert: (row) => perimeter.run(nonEmpty(row.line, "perimeter.line"), canonicalLineId(row.line, "perimeter.line"), nonEmpty(row.ns2_stoppointref, "perimeter.ns2_stoppointref")),663 insert: (row) => perimeter.run(nonEmpty(row.line, "perimeter.line"), canonicalLineId(row.line, "perimeter.line"), nonEmpty(row.ns2_stoppointref, "perimeter.ns2_stoppointref")),
638 },664 },
639 arrets: {665 arrets: {
640- required: ["arrid", "zdaid"],666+ required: ["arrid", "zdaid", "arrname"],
641- insert: (row) => arret.run(nonEmpty(row.arrid, "arrets.arrid"), nonEmpty(row.zdaid, "arrets.zdaid")),667+ insert: (row) => arret.run(nonEmpty(row.arrid, "arrets.arrid"), nonEmpty(row.zdaid, "arrets.zdaid"), optionalText(row.arrname)),
642 },668 },
643 zones: {669 zones: {
644 required: ["zdaid", "zdaname", "zdatown"],670 required: ["zdaid", "zdaname", "zdatown"],
@@ -646,7 +672,17 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
646 },672 },
647 relations: {673 relations: {
648 required: ["zdaid", "arrid", "artid"],674 required: ["zdaid", "arrid", "artid"],
649- insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), optionalText(row.artid) || null),675+ optional: ["zdcid"],
676+ insert: (row) => {
677+ const arrid = optionalText(row.arrid);
678+ const artid = optionalText(row.artid);
679+ if (arrid === "" && artid === "") {
680+ // The hierarchy can end at a ZdA or ZdC without any stop to project.
681+ if (optionalText(row.zdaid) === "") nonEmpty(row.zdcid, "relations.zdcid");
682+ return;
683+ }
684+ relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(arrid, "relations.arrid"), artid || null);
685+ },
650 },686 },
651 lines: {687 lines: {
652 required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"],688 required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"],
@@ -678,12 +714,15 @@ function runBatched(database: DatabaseSync, rows: Iterable<Readonly<Record<strin
678 }714 }
679 }715 }
680 716
681-function stringRecord(record: CsvRecord, required: readonly string[]): Readonly<Record<string, string>> {717+function stringRecord(record: CsvRecord, spec: CsvSpec): Readonly<Record<string, string>> {
682 const result: Record<string, string> = {};718 const result: Record<string, string> = {};
683- for (const key of required) {719+ for (const key of spec.required) {
684 if (!Object.hasOwn(record, key)) fail("MISSING_COLUMN", `record is missing ${key}`);720 if (!Object.hasOwn(record, key)) fail("MISSING_COLUMN", `record is missing ${key}`);
685 result[key] = String(record[key] ?? "");721 result[key] = String(record[key] ?? "");
686 }722 }
723+ for (const key of spec.optional ?? []) {
724+ if (Object.hasOwn(record, key)) result[key] = String(record[key] ?? "");
725+ }
687 return result;726 return result;
688 }727 }
689 728
@@ -691,7 +730,7 @@ function loadRecordSources(database: DatabaseSync, sources: CatalogRecordSourceS
691 const specs = prepareSpecs(database);730 const specs = prepareSpecs(database);
692 for (const key of Object.keys(specs) as (keyof CatalogRecordSourceSet)[]) {731 for (const key of Object.keys(specs) as (keyof CatalogRecordSourceSet)[]) {
693 const spec = specs[key];732 const spec = specs[key];
694- const rows = sources[key].map((row) => stringRecord(row, spec.required));733+ const rows = sources[key].map((row) => stringRecord(row, spec));
695 runBatched(database, rows, spec);734 runBatched(database, rows, spec);
696 }735 }
697 }736 }
@@ -786,6 +825,10 @@ async function loadCsvStream(
786 if (index === -1) fail("MISSING_COLUMN", `CSV is missing required column ${required}`);825 if (index === -1) fail("MISSING_COLUMN", `CSV is missing required column ${required}`);
787 mapped[required] = index;826 mapped[required] = index;
788 }827 }
828+ for (const optional of spec.optional ?? []) {
829+ const index = header.indexOf(optional);
830+ if (index !== -1) mapped[optional] = index;
831+ }
789 indexes = mapped;832 indexes = mapped;
790 continue;833 continue;
791 }834 }
@@ -1027,6 +1070,9 @@ function deriveCandidates(database: DatabaseSync): void {
1027 ELSE stop.stop_id1070 ELSE stop.stop_id
1028 END;1071 END;
1029 1072
1073+ CREATE INDEX stage_stop_zda_place ON stage_stop_zda(zdaid);
1074+ CREATE INDEX stage_stop_zda_rail_place ON stage_stop_zda(rail_zdaid);
1075+
1030 CREATE TABLE stage_terminals AS1076 CREATE TABLE stage_terminals AS
1031 SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name1077 SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name
1032 FROM stage_stop_times AS stop_time1078 FROM stage_stop_times AS stop_time
@@ -1040,6 +1086,17 @@ function deriveCandidates(database: DatabaseSync): void {
1040 AND terminal.stop_sequence = stop_time.stop_sequence;1086 AND terminal.stop_sequence = stop_time.stop_sequence;
1041 CREATE UNIQUE INDEX stage_terminals_trip ON stage_terminals(trip_id);1087 CREATE UNIQUE INDEX stage_terminals_trip ON stage_terminals(trip_id);
1042 1088
1089+ -- Retain every source occurrence before the service projection loses order.
1090+ CREATE TABLE stage_journey_stops AS
1091+ SELECT line_mode.mode, line_mode.id_line, trip.trip_id,
1092+ stop_time.stop_sequence, stop_time.stop_id, stop_time.pickup_type, stop_time.drop_off_type
1093+ FROM stage_trips AS trip
1094+ JOIN stage_routes AS route
1095+ ON route.route_id = CASE WHEN trip.route_id LIKE 'IDFM:%' THEN substr(trip.route_id, 6) ELSE trip.route_id END
1096+ JOIN stage_line_modes AS line_mode ON line_mode.id_line = route.route_id
1097+ JOIN stage_stop_times AS stop_time ON stop_time.trip_id = trip.trip_id;
1098+ CREATE INDEX stage_journey_line ON stage_journey_stops(mode, id_line, trip_id, stop_sequence);
1099+
1043 CREATE TABLE stage_gtfs_services AS1100 CREATE TABLE stage_gtfs_services AS
1044 SELECT DISTINCT trip.route_id, trip.trip_headsign, trip.direction_id,1101 SELECT DISTINCT trip.route_id, trip.trip_headsign, trip.direction_id,
1045 current_stop_time.stop_id, terminal.stop_id AS terminal_stop_id,1102 current_stop_time.stop_id, terminal.stop_id AS terminal_stop_id,
@@ -1098,6 +1155,9 @@ function deriveCandidates(database: DatabaseSync): void {
1098 AND trip.terminal_stop_id LIKE 'IDFM:%' AND trip.terminal_stop_id NOT LIKE 'IDFM:monomodalStopPlace:%')1155 AND trip.terminal_stop_id LIKE 'IDFM:%' AND trip.terminal_stop_id NOT LIKE 'IDFM:monomodalStopPlace:%')
1099 );1156 );
1100 1157
1158+ CREATE INDEX stage_routing_terminal_tuple ON stage_routing_candidates
1159+ (mode, id_line, monitoring_ref, direction_id, destination_ref, source_terminal_code);
1160+
1101 -- Detect conflicts before perimeter or attachment exclusions can hide one side.1161 -- Detect conflicts before perimeter or attachment exclusions can hide one side.
1102 CREATE TABLE stage_routing_collisions AS1162 CREATE TABLE stage_routing_collisions AS
1103 SELECT monitoring_ref, id_line, destination_ref1163 SELECT monitoring_ref, id_line, destination_ref
@@ -1150,6 +1210,7 @@ function deriveCandidates(database: DatabaseSync): void {
1150 )1210 )
1151 GROUP BY candidate.mode, candidate.zdaid, candidate.monitoring_ref, candidate.id_line,1211 GROUP BY candidate.mode, candidate.zdaid, candidate.monitoring_ref, candidate.id_line,
1152 perimeter.line, candidate.direction_id, candidate.destination_ref, candidate.source_terminal_code;1212 perimeter.line, candidate.direction_id, candidate.destination_ref, candidate.source_terminal_code;
1213+ CREATE INDEX stage_candidates_journey_group ON stage_candidates(mode, line_ref);
1153 `);1214 `);
1154 }1215 }
1155 1216
@@ -1252,6 +1313,147 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
1252 return placeResolution;1313 return placeResolution;
1253 }1314 }
1254 1315
1316+interface JourneySourceStop {
1317+ stop_id: string;
1318+ stop_name: string;
1319+ parent_station: string;
1320+ zdaid: string | null;
1321+ rail_zdaid: string;
1322+}
1323+
1324+function journeyStopIdentity(mode: TransportMode, stop: JourneySourceStop): { placeId: string; stopRef: string | null } {
1325+ const rail = mode === "RER" || mode === "TRANSILIEN";
1326+ const railProven = stop.stop_id.startsWith("IDFM:monomodalStopPlace:")
1327+ || stop.parent_station.startsWith("IDFM:monomodalStopPlace:");
1328+ const zda = rail ? (railProven ? stop.rail_zdaid : null) : stop.zdaid;
1329+ const placeId = zda === null
1330+ ? opaqueId("plc_", canonicalTuple("journey-stop", [mode, stop.stop_id]))
1331+ : createPlaceIdentity(mode, zda).placeId;
1332+ const suffix = rail ? (railProven ? stop.rail_zdaid : null)
1333+ : /^IDFM:(?!monomodalStopPlace:)[^:\s\x00-\x1f\x7f]+$/u.test(stop.stop_id) ? stop.stop_id.slice(5) : null;
1334+ return { placeId, stopRef: suffix === null ? null : `STIF:${rail ? "StopArea:SP" : "StopPoint:Q"}:${suffix}:` };
1335+}
1336+
1337+function insertJourneys(database: DatabaseSync): void {
1338+ const sourceStop = database.prepare(`
1339+ SELECT stop.*, projection.zdaid, projection.rail_zdaid
1340+ FROM stage_stops AS stop JOIN stage_stop_zda AS projection USING(stop_id)
1341+ WHERE stop.stop_id = ?
1342+ `);
1343+ const equivalentStops = database.prepare(`
1344+ SELECT stop.*, projection.zdaid, projection.rail_zdaid
1345+ FROM stage_stops AS stop JOIN stage_stop_zda AS projection USING(stop_id)
1346+ WHERE (? = 0 AND projection.zdaid = ?) OR (? = 1 AND projection.rail_zdaid = ?)
1347+ `);
1348+ const equivalentArrets = database.prepare("SELECT arrid, arrname FROM stage_arrets WHERE zdaid = ?");
1349+ const insertGroup = database.prepare("INSERT INTO journey_groups VALUES (?, ?, ?)");
1350+ const insertRow = database.prepare("INSERT INTO journey_rows VALUES (?, ?, ?, ?)");
1351+ const insertServiceJourney = database.prepare("INSERT INTO service_journeys VALUES (?, ?, ?)");
1352+ const terminalSources = database.prepare(`
1353+ SELECT DISTINCT terminal_stop_id FROM stage_routing_candidates
1354+ WHERE mode = ? AND id_line = ? AND monitoring_ref = ? AND direction_id = ?
1355+ AND destination_ref = ? AND source_terminal_code = ?
1356+ `);
1357+ const groups = database.prepare("SELECT DISTINCT mode, id_line, line_ref FROM stage_candidates ORDER BY mode, line_ref");
1358+ for (const group of groups.iterate() as Iterable<QueryRow>) {
1359+ const mode = transportMode(group.mode);
1360+ const lineRef = String(group.line_ref);
1361+ const groupId = opaqueId("grp_", JSON.stringify([mode, lineRef]));
1362+ const places = new Map<string, JourneyPlace>();
1363+ const terminals = new Map<string, JourneyTerminal>();
1364+ const patterns = new Map<string, JourneyPattern>();
1365+ let patternBytes = 0;
1366+ const stopCache = new Map<string, JourneySourceStop>();
1367+ const resolveStop = (stopId: string): JourneySourceStop => {
1368+ let stop = stopCache.get(stopId);
1369+ if (stop === undefined) {
1370+ stop = sourceStop.get(stopId) as unknown as JourneySourceStop | undefined;
1371+ if (stop === undefined) fail("CATALOG_JOURNEY", `missing GTFS stop ${stopId}`);
1372+ stopCache.set(stopId, stop);
1373+ }
1374+ return stop;
1375+ };
1376+ const addPlace = (stop: JourneySourceStop): { placeId: string; stopRef: string | null } => {
1377+ const identity = journeyStopIdentity(mode, stop);
1378+ const previous = places.get(identity.placeId);
1379+ if (previous === undefined || stop.stop_name < previous.label) {
1380+ places.set(identity.placeId, { kind: "place", placeId: identity.placeId, label: stop.stop_name });
1381+ if (places.size > journeyPatterns.JOURNEY_LIMITS.places) fail("CATALOG_JOURNEY", `too many places in ${groupId}`);
1382+ }
1383+ return identity;
1384+ };
1385+ let tripId: string | undefined;
1386+ let stops: JourneyStop[] = [];
1387+ let terminalStop: JourneySourceStop | undefined;
1388+ const finishTrip = (): void => {
1389+ if (terminalStop === undefined) return;
1390+ const terminalId = opaqueId("term_", JSON.stringify([mode, terminalStop.stop_id]));
1391+ if (!terminals.has(terminalId)) {
1392+ const identity = addPlace(terminalStop);
1393+ const refs = new Set<string>(identity.stopRef === null ? [] : [identity.stopRef]);
1394+ const labels = new Set<string>([terminalStop.stop_name]);
1395+ const rail = mode === "RER" || mode === "TRANSILIEN";
1396+ const zda = rail ? terminalStop.rail_zdaid : terminalStop.zdaid;
1397+ if (zda !== null && (!rail || identity.stopRef !== null)) {
1398+ for (const equivalent of equivalentStops.iterate(Number(rail), zda, Number(rail), zda) as unknown as Iterable<JourneySourceStop>) {
1399+ const other = journeyStopIdentity(mode, equivalent);
1400+ if (other.placeId !== identity.placeId) continue;
1401+ if (other.stopRef !== null) refs.add(other.stopRef);
1402+ labels.add(equivalent.stop_name);
1403+ addPlace(equivalent);
1404+ }
1405+ for (const equivalent of equivalentArrets.iterate(zda) as Iterable<QueryRow>) {
1406+ if (!rail) refs.add(`STIF:StopPoint:Q:${String(equivalent.arrid)}:`);
1407+ if (String(equivalent.arrname) !== "") labels.add(String(equivalent.arrname));
1408+ }
1409+ if (refs.size > journeyPatterns.JOURNEY_LIMITS.terminalRefs || labels.size > journeyPatterns.JOURNEY_LIMITS.terminalLabels) {
1410+ fail("CATALOG_JOURNEY", `too many terminal aliases in ${groupId}`);
1411+ }
1412+ }
1413+ terminals.set(terminalId, { kind: "terminal", terminalId, terminalPlaceId: identity.placeId, refs: [...refs].sort(), labels: [...labels].sort() });
1414+ if (terminals.size > journeyPatterns.JOURNEY_LIMITS.terminals) fail("CATALOG_JOURNEY", `too many terminals in ${groupId}`);
1415+ }
1416+ const patternId = opaqueId("pat_", JSON.stringify([mode, lineRef, terminalId,
1417+ stops.map((stop) => [stop.stopRef, stop.placeId, stop.pickupType, stop.dropOffType])]));
1418+ const pattern: JourneyPattern = { kind: "pattern", patternId, terminalId, stops };
1419+ if (!patterns.has(patternId)) patternBytes += Buffer.byteLength(JSON.stringify(pattern));
1420+ if (patternBytes > journeyPatterns.JOURNEY_LIMITS.groupBytes) fail("CATALOG_JOURNEY", `journey group exceeds byte budget: ${groupId}`);
1421+ patterns.set(patternId, pattern);
1422+ if (patterns.size > journeyPatterns.JOURNEY_LIMITS.patterns) fail("CATALOG_JOURNEY", `too many patterns in ${groupId}`);
1423+ stops = [];
1424+ };
1425+ for (const occurrence of database.prepare(`
1426+ SELECT * FROM stage_journey_stops WHERE mode = ? AND id_line = ?
1427+ ORDER BY trip_id, stop_sequence
1428+ `).iterate(mode, String(group.id_line)) as Iterable<QueryRow>) {
1429+ if (tripId !== occurrence.trip_id) {
1430+ finishTrip();
1431+ tripId = String(occurrence.trip_id);
1432+ }
1433+ terminalStop = resolveStop(String(occurrence.stop_id));
1434+ stops.push({ ...addPlace(terminalStop), pickupType: Number(occurrence.pickup_type) as JourneyStop["pickupType"],
1435+ dropOffType: Number(occurrence.drop_off_type) as JourneyStop["dropOffType"] });
1436+ if (stops.length > journeyPatterns.JOURNEY_LIMITS.stops) fail("CATALOG_JOURNEY", `too many occurrences in ${groupId}`);
1437+ }
1438+ finishTrip();
1439+ insertGroup.run(groupId, mode, lineRef);
1440+ for (const [id, value] of places) insertRow.run(groupId, value.kind, id, JSON.stringify(value));
1441+ for (const [id, value] of terminals) insertRow.run(groupId, value.kind, id, JSON.stringify(value));
1442+ for (const [id, value] of patterns) insertRow.run(groupId, value.kind, id, JSON.stringify(value));
1443+ for (const candidate of database.prepare("SELECT * FROM stage_candidates WHERE mode = ? AND line_ref = ?")
1444+ .iterate(mode, lineRef) as Iterable<QueryRow>) {
1445+ const serviceId = createServiceIdentity({ mode, lineId: String(candidate.id_line), monitoringRef: String(candidate.monitoring_ref),
1446+ directionId: String(candidate.direction_id), destinationRef: String(candidate.source_terminal_code) }).serviceId;
1447+ const terminalPlaces = new Set<string>();
1448+ for (const terminal of terminalSources.iterate(mode, String(candidate.id_line), String(candidate.monitoring_ref),
1449+ String(candidate.direction_id), String(candidate.destination_ref), String(candidate.source_terminal_code)) as Iterable<QueryRow>) {
1450+ terminalPlaces.add(journeyStopIdentity(mode, resolveStop(String(terminal.terminal_stop_id))).placeId);
1451+ }
1452+ insertServiceJourney.run(serviceId, groupId, terminalPlaces.size === 1 ? [...terminalPlaces][0]! : null);
1453+ }
1454+ }
1455+}
1456+
1255 function validateByteBounds(database: DatabaseSync): void {1457 function validateByteBounds(database: DatabaseSync): void {
1256 const invalidPlace = database.prepare(`1458 const invalidPlace = database.prepare(`
1257 SELECT place_id FROM places1459 SELECT place_id FROM places
@@ -1309,6 +1511,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult {
1309 if (integrity.length !== 1 || String(integrity[0]?.integrity_check) !== "ok") fail("CATALOG_INTEGRITY", "SQLite integrity_check failed");1511 if (integrity.length !== 1 || String(integrity[0]?.integrity_check) !== "ok") fail("CATALOG_INTEGRITY", "SQLite integrity_check failed");
1310 if ((database.prepare("PRAGMA foreign_key_check").all() as QueryRow[]).length !== 0) fail("CATALOG_FOREIGN_KEY", "SQLite foreign_key_check failed");1512 if ((database.prepare("PRAGMA foreign_key_check").all() as QueryRow[]).length !== 0) fail("CATALOG_FOREIGN_KEY", "SQLite foreign_key_check failed");
1311 validateByteBounds(database);1513 validateByteBounds(database);
1514+ validateJourneyCatalog(database);
1312 1515
1313 const missingSearch = database.prepare(`1516 const missingSearch = database.prepare(`
1314 SELECT 1 FROM places AS place1517 SELECT 1 FROM places AS place
@@ -1433,6 +1636,7 @@ function dropStaging(database: DatabaseSync): void {
1433 DROP TABLE stage_routing_collisions;1636 DROP TABLE stage_routing_collisions;
1434 DROP TABLE stage_routing_candidates;1637 DROP TABLE stage_routing_candidates;
1435 DROP TABLE stage_gtfs_services;1638 DROP TABLE stage_gtfs_services;
1639+ DROP TABLE stage_journey_stops;
1436 DROP TABLE stage_terminals;1640 DROP TABLE stage_terminals;
1437 DROP TABLE stage_stop_zda;1641 DROP TABLE stage_stop_zda;
1438 DROP TABLE stage_line_modes;1642 DROP TABLE stage_line_modes;
@@ -1474,6 +1678,7 @@ async function build(
1474 validateStagedPerimeter(database);1678 validateStagedPerimeter(database);
1475 validateCandidateSemanticTuples(database);1679 validateCandidateSemanticTuples(database);
1476 const placeResolution = insertFinalCatalog(database, sourceRevision, createdAt);1680 const placeResolution = insertFinalCatalog(database, sourceRevision, createdAt);
1681+ insertJourneys(database);
1477 dropStaging(database);1682 dropStaging(database);
1478 const result = validateOpenCatalog(database);1683 const result = validateOpenCatalog(database);
1479 database.close();1684 database.close();
modified packages/catalog/src/catalog.ts +124 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/catalog/src/static-catalog.ts +214 -20
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/catalog/test/catalog-import.test.ts +244 -9
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/catalog/test/catalog.test.ts +88 -47
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/catalog/test/static-catalog.test.js +426 -23
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/catalog-client.js +162 -96
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/configuration-sync.js +28 -0
@@ -46,6 +46,33 @@ function createConfigurationSync(options) {
4646 return active !== null && active.phase === "COMMITTING" && matches(active, request);
4747 }
4848
49+ function enrichPhoneFavorites(favorites, lifecycleGeneration) {
50+ if (!contracts.isPhoneFavoriteList(favorites)) return;
51+ [active, committed, previous].forEach(function (target) {
52+ if (!target || target.lifecycleGeneration !== lifecycleGeneration) return;
53+ target.favorites.forEach(function (favorite) {
54+ var index;
55+ var source;
56+ for (index = 0; index < favorites.length; index += 1) {
57+ source = favorites[index];
58+ if (source.id !== favorite.id || source.serviceId !== favorite.serviceId) continue;
59+ if (favorite.arrivalPlaceId === null && source.arrivalPlaceId !== null) {
60+ favorite.arrivalPlaceId = source.arrivalPlaceId;
61+ }
62+ if (!Object.prototype.hasOwnProperty.call(favorite, "routing")
63+ && Object.prototype.hasOwnProperty.call(source, "routing")) {
64+ favorite.routing = {
65+ monitoringRef: source.routing.monitoringRef,
66+ lineRef: source.routing.lineRef,
67+ destinationRef: source.routing.destinationRef
68+ };
69+ }
70+ break;
71+ }
72+ });
73+ });
74+ }
75+
4976 function hello(message) {
5077 var decoded = codec.decodeHello(message);
5178 if (decoded === null) return { consumed: false, changed: false };
@@ -198,6 +225,7 @@ function createConfigurationSync(options) {
198225 return {
199226 binding: binding, ready: ready, hello: hello, synchronize: synchronize, receive: receive,
200227 resolveDataBinding: resolveDataBinding, pendingDataBinding: pendingDataBinding,
228+ enrichPhoneFavorites: enrichPhoneFavorites,
201229 discardTransaction: discardTransaction, clear: clear
202230 };
203231 }
@@ -46,6 +46,33 @@ function createConfigurationSync(options) {
46 return active !== null && active.phase === "COMMITTING" && matches(active, request);46 return active !== null && active.phase === "COMMITTING" && matches(active, request);
47 }47 }
48 48
49+ function enrichPhoneFavorites(favorites, lifecycleGeneration) {
50+ if (!contracts.isPhoneFavoriteList(favorites)) return;
51+ [active, committed, previous].forEach(function (target) {
52+ if (!target || target.lifecycleGeneration !== lifecycleGeneration) return;
53+ target.favorites.forEach(function (favorite) {
54+ var index;
55+ var source;
56+ for (index = 0; index < favorites.length; index += 1) {
57+ source = favorites[index];
58+ if (source.id !== favorite.id || source.serviceId !== favorite.serviceId) continue;
59+ if (favorite.arrivalPlaceId === null && source.arrivalPlaceId !== null) {
60+ favorite.arrivalPlaceId = source.arrivalPlaceId;
61+ }
62+ if (!Object.prototype.hasOwnProperty.call(favorite, "routing")
63+ && Object.prototype.hasOwnProperty.call(source, "routing")) {
64+ favorite.routing = {
65+ monitoringRef: source.routing.monitoringRef,
66+ lineRef: source.routing.lineRef,
67+ destinationRef: source.routing.destinationRef
68+ };
69+ }
70+ break;
71+ }
72+ });
73+ });
74+ }
75+
49 function hello(message) {76 function hello(message) {
50 var decoded = codec.decodeHello(message);77 var decoded = codec.decodeHello(message);
51 if (decoded === null) return { consumed: false, changed: false };78 if (decoded === null) return { consumed: false, changed: false };
@@ -198,6 +225,7 @@ function createConfigurationSync(options) {
198 return {225 return {
199 binding: binding, ready: ready, hello: hello, synchronize: synchronize, receive: receive,226 binding: binding, ready: ready, hello: hello, synchronize: synchronize, receive: receive,
200 resolveDataBinding: resolveDataBinding, pendingDataBinding: pendingDataBinding,227 resolveDataBinding: resolveDataBinding, pendingDataBinding: pendingDataBinding,
228+ enrichPhoneFavorites: enrichPhoneFavorites,
201 discardTransaction: discardTransaction, clear: clear229 discardTransaction: discardTransaction, clear: clear
202 };230 };
203 }231 }
modified packages/companion/src/configuration.js +53 -23
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/contracts.js +25 -6
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/display-copy.json +10 -0
@@ -233,5 +233,15 @@
233233 "token": "retry",
234234 "en": "Select: retry.",
235235 "fr": "Select : réessayer."
236+ },
237+ {
238+ "token": "journeyUncertainLegend",
239+ "en": "? : journey uncertain",
240+ "fr": "? : trajet incertain"
241+ },
242+ {
243+ "token": "journeyUncertainShort",
244+ "en": "Uncertain trip",
245+ "fr": "Trajet incertain"
236246 }
237247 ]
\ No newline at end of file
@@ -233,5 +233,15 @@
233 "token": "retry",233 "token": "retry",
234 "en": "Select: retry.",234 "en": "Select: retry.",
235 "fr": "Select : réessayer."235 "fr": "Select : réessayer."
236+ },
237+ {
238+ "token": "journeyUncertainLegend",
239+ "en": "? : journey uncertain",
240+ "fr": "? : trajet incertain"
241+ },
242+ {
243+ "token": "journeyUncertainShort",
244+ "en": "Uncertain trip",
245+ "fr": "Trajet incertain"
236 }246 }
237 ]247 ]
\ No newline at end of file\ No newline at end of file
modified packages/companion/src/display.js +2 -2
@@ -71,8 +71,8 @@ function departureRecord(view) {
7171 + layout.fixed(palette, 1) + timestamp(view.trafficCheckedAt) + layout.fixed(departures.length, 1);
7272 for (var i = 0; i < departures.length; i += 1) {
7373 var status = contracts.DEPARTURE_STATUS.indexOf(departures[i].status);
74- if (status < 0) throw new TypeError("Invalid departure status");
75- record += timestamp(departures[i].expectedAt) + layout.fixed(status, 1);
74+ if (status < 0 || typeof departures[i].journeyUncertain !== "boolean") throw new TypeError("Invalid departure status");
75+ record += timestamp(departures[i].expectedAt) + layout.fixed(status | (departures[i].journeyUncertain ? 4 : 0), 1);
7676 }
7777 return record;
7878 }
@@ -71,8 +71,8 @@ function departureRecord(view) {
71 + layout.fixed(palette, 1) + timestamp(view.trafficCheckedAt) + layout.fixed(departures.length, 1);71 + layout.fixed(palette, 1) + timestamp(view.trafficCheckedAt) + layout.fixed(departures.length, 1);
72 for (var i = 0; i < departures.length; i += 1) {72 for (var i = 0; i < departures.length; i += 1) {
73 var status = contracts.DEPARTURE_STATUS.indexOf(departures[i].status);73 var status = contracts.DEPARTURE_STATUS.indexOf(departures[i].status);
74- if (status < 0) throw new TypeError("Invalid departure status");74+ if (status < 0 || typeof departures[i].journeyUncertain !== "boolean") throw new TypeError("Invalid departure status");
75- record += timestamp(departures[i].expectedAt) + layout.fixed(status, 1);75+ record += timestamp(departures[i].expectedAt) + layout.fixed(status | (departures[i].journeyUncertain ? 4 : 0), 1);
76 }76 }
77 return record;77 return record;
78 }78 }
modified packages/companion/src/index.js +156 -96
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/journey-patterns.js +233 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/prim-client.js +7 -2
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/prim-departures.js +71 -31
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/test/catalog-client.test.js +213 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/companion.test.js +280 -31
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/test/configuration-journey.test.js +223 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/prim-client.test.js +74 -21
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/prim-normalization.test.js +234 -148
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/protocol.test.js +42 -3
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/index.html +7 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/src/catalog-client.js +134 -3
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/src/config-core.js +225 -47
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/src/config-page.js +160 -44
Large diff collapsed to keep the page fast — load it when you need it.
added packages/config-page/src/generated/journey-patterns.js +1028 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/styles/config-page.css +0 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/test/catalog-client.test.js +489 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/test/config-page.test.ts +365 -99
Large diff collapsed to keep the page fast — load it when you need it.
added packages/config-page/test/journey-patterns.test.js +210 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/contracts/src/index.ts +109 -6
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/contracts/test/contracts.test.ts +71 -13
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/packed.js +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/runtime.js +3 -3
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/ui.js +21 -8
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/d2-records.js +5 -4
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/display-copy.test.js +21 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/fixture-boot.test.js +16 -8
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/main.test.js +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/packed.test.js +28 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/runtime.test.js +4 -3
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/ui.test.js +71 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/build-config-site.mjs +2 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/generate-display-copy.mjs +8 -6
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/generate-journey-patterns.mjs +33 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/measure-config-page.mjs +1 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/measure-radio.mjs +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/refresh-catalog.mjs +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/test.mjs +2 -0
Large diff collapsed to keep the page fast — load it when you need it.