feat(arrivals): support departure-to-arrival favorites (#28) #29
Merged@github-anthodev wants to merge
feature/departure-arrival-journeys/arrivals/28 into developMerged as
102d71929e93.64 files changed
modified
README.md +10 -7 | @@ -7,7 +7,7 @@ | ||
| 7 | 7 | <p align="center"> |
| 8 | 8 | <strong>Next arrivals and line traffic at a glance, on your Pebble.</strong> |
| 9 | 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 | 11 | </p> |
| 12 | 12 | |
| 13 | 13 | <p align="center"> |
| @@ -28,7 +28,7 @@ | ||
| 28 | 28 | |
| 29 | 29 | ## 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 | 33 | <p align="center"> |
| 34 | 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 | 42 | ## 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. | |
| 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. | |
| 46 | 49 | - **Traffic view.** Select again on an arrival board to page through the current service messages for that line. |
| 47 | 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 | 52 | - **English and French.** The watch interface and the settings page are both bilingual. |
| 50 | 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 | 66 | 1. Install [Lapin Futé from RePebble](https://apps.repebble.com/6d6aa01b7ecb4cfea469a183). |
| 64 | 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 | 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 | 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 | 104 | 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 | 109 | ```sh |
| 107 | 110 | 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 | ## About | 29 | ## 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 | ## Features | 42 | ## 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 build | 104 | 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 | ```sh | 109 | ```sh |
| 107 | node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog | 110 | 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 @@ | ||
| 17 | 17 | { "id": "fixture-2026-08-b", "preservedTripId": "trip-bus-preserved", "removedTripId": "trip-metro-west" }, |
| 18 | 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 | 61 | "sources": { |
| 21 | 62 | "agency": [ |
| 22 | 63 | { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" } |
| @@ -47,12 +88,12 @@ | ||
| 47 | 88 | { "zdaid": "ZDA-FUNICULAR", "zdaname": "Montmartre", "zdatown": "Paris" } |
| 48 | 89 | ], |
| 49 | 90 | "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" } | |
| 56 | 97 | ], |
| 57 | 98 | "relations": [ |
| 58 | 99 | { "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 @@ | ||
| 25 | 25 | "aimedAt": 1788000100, |
| 26 | 26 | "minutes": 2, |
| 27 | 27 | "status": "DELAYED", |
| 28 | + "journeyUncertain": false, | |
| 28 | 29 | "nextIntervalMinutes": 4 |
| 29 | 30 | }, |
| 30 | 31 | { |
| 31 | 32 | "expectedAt": 1788000360, |
| 32 | 33 | "minutes": 6, |
| 33 | - "status": "ON_TIME" | |
| 34 | + "status": "ON_TIME", | |
| 35 | + "journeyUncertain": false | |
| 34 | 36 | } |
| 35 | 37 | ] |
| 36 | 38 | } |
| @@ -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": 4 | 29 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C1001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:1001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:32:00Z", |
| 18 | 18 | "AimedDepartureTime": "2026-01-15T08:31:00Z", |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "MonitoredVehicleJourney": { |
| 27 | 27 | "LineRef": { "value": "IDFM:C1001" }, |
| 28 | 28 | "DirectionRef": { "value": "IDFM:1001D" }, |
| 29 | - "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 29 | + "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" }, | |
| 30 | 30 | "MonitoredCall": { |
| 31 | 31 | "ExpectedDepartureTime": "2026-01-15T08:38:00Z", |
| 32 | 32 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C3001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:3001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:32:00Z", |
| 18 | 18 | "AimedDepartureTime": "2026-01-15T08:31:30Z", |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "MonitoredVehicleJourney": { |
| 27 | 27 | "LineRef": { "value": "IDFM:C3001" }, |
| 28 | 28 | "DirectionRef": { "value": "IDFM:3001D" }, |
| 29 | - "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 29 | + "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" }, | |
| 30 | 30 | "MonitoredCall": { |
| 31 | 31 | "ExpectedDepartureTime": "2026-01-15T08:35:00Z", |
| 32 | 32 | "AimedDepartureTime": "2026-01-15T08:34:30Z", |
| @@ -40,7 +40,7 @@ | ||
| 40 | 40 | "MonitoredVehicleJourney": { |
| 41 | 41 | "LineRef": { "value": "IDFM:C3001" }, |
| 42 | 42 | "DirectionRef": { "value": "IDFM:3001D" }, |
| 43 | - "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 43 | + "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" }, | |
| 44 | 44 | "MonitoredCall": { |
| 45 | 45 | "ExpectedDepartureTime": "2026-01-15T08:40:00Z", |
| 46 | 46 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C2001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:2001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:37:00Z", |
| 18 | 18 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C2001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:2001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "DepartureStatus": "ontime" |
| 18 | 18 | } |
| @@ -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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C2001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:2001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:2001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:2001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:34:00Z", |
| 18 | 18 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C4001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:4001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:33:00Z", |
| 18 | 18 | "AimedDepartureTime": "2026-01-15T08:32:30Z", |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "MonitoredVehicleJourney": { |
| 27 | 27 | "LineRef": { "value": "IDFM:C4001" }, |
| 28 | 28 | "DirectionRef": { "value": "IDFM:4001D" }, |
| 29 | - "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 29 | + "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" }, | |
| 30 | 30 | "MonitoredCall": { |
| 31 | 31 | "AimedDepartureTime": "2026-01-15T08:41:00Z" |
| 32 | 32 | } |
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | "MonitoredVehicleJourney": { |
| 39 | 39 | "LineRef": { "value": "IDFM:C4001" }, |
| 40 | 40 | "DirectionRef": { "value": "IDFM:4001D" }, |
| 41 | - "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 41 | + "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" }, | |
| 42 | 42 | "MonitoredCall": { |
| 43 | 43 | "ExpectedDepartureTime": "2026-01-15T08:49:00Z", |
| 44 | 44 | "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 @@ | ||
| 10 | 10 | "MonitoredVehicleJourney": { |
| 11 | 11 | "LineRef": { "value": "IDFM:C1001" }, |
| 12 | 12 | "DirectionRef": { "value": "IDFM:1099D" }, |
| 13 | - "DestinationRef": { "value": "IDFM:1099DST" }, | |
| 13 | + "DestinationRef": { "value": "STIF:StopPoint:Q:1099DST:" }, | |
| 14 | 14 | "MonitoredCall": { |
| 15 | 15 | "ExpectedDepartureTime": "2026-01-15T08:31:00Z", |
| 16 | 16 | "AimedDepartureTime": "2026-01-15T08:31:00Z", |
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | "MonitoredVehicleJourney": { |
| 25 | 25 | "LineRef": { "value": "IDFM:C1001" }, |
| 26 | 26 | "DirectionRef": { "value": "IDFM:1001D" }, |
| 27 | - "DestinationRef": { "value": "IDFM:1001DST" }, | |
| 27 | + "DestinationRef": { "value": "STIF:StopPoint:Q:1001DST:" }, | |
| 28 | 28 | "MonitoredCall": { |
| 29 | 29 | "ExpectedDepartureTime": "2026-01-15T08:34:00Z", |
| 30 | 30 | "AimedDepartureTime": "2026-01-15T08:33:00Z", |
| @@ -42,7 +42,7 @@ | ||
| 42 | 42 | "MonitoredVehicleJourney": { |
| 43 | 43 | "LineRef": { "value": "IDFM:C1099" }, |
| 44 | 44 | "DirectionRef": { "value": "IDFM:1099D" }, |
| 45 | - "DestinationRef": { "value": "IDFM:1099DST" }, | |
| 45 | + "DestinationRef": { "value": "STIF:StopPoint:Q:1099DST:" }, | |
| 46 | 46 | "MonitoredCall": { |
| 47 | 47 | "ExpectedDepartureTime": "2026-01-15T08:36:00Z", |
| 48 | 48 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C4001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:4001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:33:00Z", |
| 18 | 18 | "AimedDepartureTime": "2026-01-15T08:32:30Z", |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "MonitoredVehicleJourney": { |
| 27 | 27 | "LineRef": { "value": "IDFM:C4001" }, |
| 28 | 28 | "DirectionRef": { "value": "IDFM:4001D" }, |
| 29 | - "DestinationRef": { "value": "IDFM:4001DST" }, | |
| 29 | + "DestinationRef": { "value": "STIF:StopPoint:Q:4001DST:" }, | |
| 30 | 30 | "MonitoredCall": { |
| 31 | 31 | "ExpectedDepartureTime": "2026-01-15T08:47:00Z", |
| 32 | 32 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C5001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:5001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "AimedDepartureTime": "2026-01-15T08:44:00Z" |
| 18 | 18 | } |
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | "MonitoredVehicleJourney": { |
| 25 | 25 | "LineRef": { "value": "IDFM:C5001" }, |
| 26 | 26 | "DirectionRef": { "value": "IDFM:5001D" }, |
| 27 | - "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 27 | + "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" }, | |
| 28 | 28 | "MonitoredCall": { |
| 29 | 29 | "AimedDepartureTime": "2026-01-15T08:58:00Z" |
| 30 | 30 | } |
| @@ -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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C3001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:3001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:3001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:3001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:36:00Z", |
| 18 | 18 | "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 @@ | ||
| 12 | 12 | "MonitoredVehicleJourney": { |
| 13 | 13 | "LineRef": { "value": "IDFM:C5001" }, |
| 14 | 14 | "DirectionRef": { "value": "IDFM:5001D" }, |
| 15 | - "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 15 | + "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" }, | |
| 16 | 16 | "MonitoredCall": { |
| 17 | 17 | "ExpectedDepartureTime": "2026-01-15T08:41:00Z", |
| 18 | 18 | "AimedDepartureTime": "2026-01-15T08:40:00Z", |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "MonitoredVehicleJourney": { |
| 27 | 27 | "LineRef": { "value": "IDFM:C5001" }, |
| 28 | 28 | "DirectionRef": { "value": "IDFM:5001D" }, |
| 29 | - "DestinationRef": { "value": "IDFM:5001DST" }, | |
| 29 | + "DestinationRef": { "value": "STIF:StopPoint:Q:5001DST:" }, | |
| 30 | 30 | "MonitoredCall": { |
| 31 | 31 | "ExpectedDepartureTime": "2026-01-15T08:55:00Z", |
| 32 | 32 | "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 { | ||
| 22 | 22 | SCHEMA_VERSION, |
| 23 | 23 | TRANSPORT_MODE, |
| 24 | 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 | 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 | 31 | export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000; |
| 30 | 32 | export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3; |
| 31 | 33 | export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3; |
| @@ -166,6 +168,7 @@ export interface CatalogBuildResult { | ||
| 166 | 168 | |
| 167 | 169 | interface CsvSpec { |
| 168 | 170 | readonly required: readonly string[]; |
| 171 | + readonly optional?: readonly string[]; | |
| 169 | 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 | 296 | let tuple = `lapin-fute:${namespace}:v1`; |
| 294 | 297 | for (const field of fields) tuple += `|${Buffer.byteLength(field, "utf8")}:${field}`; |
| 295 | 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 | 302 | return prefix + createHash("sha256").update(tuple, "utf8").digest("base64url"); |
| 300 | 303 | } |
| 301 | 304 | |
| @@ -507,6 +510,25 @@ function createSchema(database: DatabaseSync): void { | ||
| 507 | 510 | destination_ref TEXT NOT NULL, |
| 508 | 511 | canonical_tuple TEXT NOT NULL UNIQUE |
| 509 | 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 | 532 | CREATE VIRTUAL TABLE place_search USING fts5( |
| 511 | 533 | search_text, |
| 512 | 534 | place_id UNINDEXED, |
| @@ -529,6 +551,7 @@ function createSchema(database: DatabaseSync): void { | ||
| 529 | 551 | stop_id TEXT NOT NULL, |
| 530 | 552 | stop_sequence INTEGER NOT NULL, |
| 531 | 553 | pickup_type INTEGER NOT NULL, |
| 554 | + drop_off_type INTEGER NOT NULL, | |
| 532 | 555 | PRIMARY KEY (trip_id, stop_sequence) |
| 533 | 556 | ) STRICT; |
| 534 | 557 | CREATE TABLE stage_stops ( |
| @@ -554,7 +577,8 @@ function createSchema(database: DatabaseSync): void { | ||
| 554 | 577 | ) STRICT; |
| 555 | 578 | CREATE TABLE stage_arrets ( |
| 556 | 579 | arrid TEXT PRIMARY KEY, |
| 557 | - zdaid TEXT NOT NULL | |
| 580 | + zdaid TEXT NOT NULL, | |
| 581 | + arrname TEXT NOT NULL | |
| 558 | 582 | ) STRICT; |
| 559 | 583 | CREATE TABLE stage_zones ( |
| 560 | 584 | zdaid TEXT PRIMARY KEY, |
| @@ -587,12 +611,12 @@ function createSchema(database: DatabaseSync): void { | ||
| 587 | 611 | function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogRecordSourceSet, CsvSpec>> { |
| 588 | 612 | const route = database.prepare("INSERT INTO stage_routes VALUES (?, ?, ?)"); |
| 589 | 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 | 615 | const stop = database.prepare("INSERT INTO stage_stops VALUES (?, ?, ?)"); |
| 592 | 616 | const agency = database.prepare("INSERT OR IGNORE INTO stage_agency VALUES (?)"); |
| 593 | 617 | const code = database.prepare("INSERT OR IGNORE INTO stage_codes VALUES (?, ?, ?, ?)"); |
| 594 | 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 | 620 | const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)"); |
| 597 | 621 | const relation = database.prepare("INSERT INTO stage_relations VALUES (?, ?, ?)"); |
| 598 | 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 | 634 | stopTimes: { |
| 611 | 635 | required: ["trip_id", "stop_id", "stop_sequence", "pickup_type"], |
| 636 | + optional: ["drop_off_type"], | |
| 612 | 637 | 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); | |
| 616 | 642 | }, |
| 617 | 643 | }, |
| 618 | 644 | stops: { |
| @@ -637,8 +663,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco | ||
| 637 | 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 | 665 | 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)), | |
| 642 | 668 | }, |
| 643 | 669 | zones: { |
| 644 | 670 | required: ["zdaid", "zdaname", "zdatown"], |
| @@ -646,7 +672,17 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco | ||
| 646 | 672 | }, |
| 647 | 673 | relations: { |
| 648 | 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 | 687 | lines: { |
| 652 | 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 | 718 | const result: Record<string, string> = {}; |
| 683 | - for (const key of required) { | |
| 719 | + for (const key of spec.required) { | |
| 684 | 720 | if (!Object.hasOwn(record, key)) fail("MISSING_COLUMN", `record is missing ${key}`); |
| 685 | 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 | 726 | return result; |
| 688 | 727 | } |
| 689 | 728 | |
| @@ -691,7 +730,7 @@ function loadRecordSources(database: DatabaseSync, sources: CatalogRecordSourceS | ||
| 691 | 730 | const specs = prepareSpecs(database); |
| 692 | 731 | for (const key of Object.keys(specs) as (keyof CatalogRecordSourceSet)[]) { |
| 693 | 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 | 734 | runBatched(database, rows, spec); |
| 696 | 735 | } |
| 697 | 736 | } |
| @@ -786,6 +825,10 @@ async function loadCsvStream( | ||
| 786 | 825 | if (index === -1) fail("MISSING_COLUMN", `CSV is missing required column ${required}`); |
| 787 | 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 | 832 | indexes = mapped; |
| 790 | 833 | continue; |
| 791 | 834 | } |
| @@ -1027,6 +1070,9 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1027 | 1070 | ELSE stop.stop_id |
| 1028 | 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 | 1076 | CREATE TABLE stage_terminals AS |
| 1031 | 1077 | SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name |
| 1032 | 1078 | FROM stage_stop_times AS stop_time |
| @@ -1040,6 +1086,17 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1040 | 1086 | AND terminal.stop_sequence = stop_time.stop_sequence; |
| 1041 | 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 | 1100 | CREATE TABLE stage_gtfs_services AS |
| 1044 | 1101 | SELECT DISTINCT trip.route_id, trip.trip_headsign, trip.direction_id, |
| 1045 | 1102 | current_stop_time.stop_id, terminal.stop_id AS terminal_stop_id, |
| @@ -1098,6 +1155,9 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1098 | 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 | 1161 | -- Detect conflicts before perimeter or attachment exclusions can hide one side. |
| 1102 | 1162 | CREATE TABLE stage_routing_collisions AS |
| 1103 | 1163 | SELECT monitoring_ref, id_line, destination_ref |
| @@ -1150,6 +1210,7 @@ function deriveCandidates(database: DatabaseSync): void { | ||
| 1150 | 1210 | ) |
| 1151 | 1211 | GROUP BY candidate.mode, candidate.zdaid, candidate.monitoring_ref, candidate.id_line, |
| 1152 | 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 | 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 | 1457 | function validateByteBounds(database: DatabaseSync): void { |
| 1256 | 1458 | const invalidPlace = database.prepare(` |
| 1257 | 1459 | SELECT place_id FROM places |
| @@ -1309,6 +1511,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult { | ||
| 1309 | 1511 | if (integrity.length !== 1 || String(integrity[0]?.integrity_check) !== "ok") fail("CATALOG_INTEGRITY", "SQLite integrity_check failed"); |
| 1310 | 1512 | if ((database.prepare("PRAGMA foreign_key_check").all() as QueryRow[]).length !== 0) fail("CATALOG_FOREIGN_KEY", "SQLite foreign_key_check failed"); |
| 1311 | 1513 | validateByteBounds(database); |
| 1514 | + validateJourneyCatalog(database); | |
| 1312 | 1515 | |
| 1313 | 1516 | const missingSearch = database.prepare(` |
| 1314 | 1517 | SELECT 1 FROM places AS place |
| @@ -1433,6 +1636,7 @@ function dropStaging(database: DatabaseSync): void { | ||
| 1433 | 1636 | DROP TABLE stage_routing_collisions; |
| 1434 | 1637 | DROP TABLE stage_routing_candidates; |
| 1435 | 1638 | DROP TABLE stage_gtfs_services; |
| 1639 | + DROP TABLE stage_journey_stops; | |
| 1436 | 1640 | DROP TABLE stage_terminals; |
| 1437 | 1641 | DROP TABLE stage_stop_zda; |
| 1438 | 1642 | DROP TABLE stage_line_modes; |
| @@ -1474,6 +1678,7 @@ async function build( | ||
| 1474 | 1678 | validateStagedPerimeter(database); |
| 1475 | 1679 | validateCandidateSemanticTuples(database); |
| 1476 | 1680 | const placeResolution = insertFinalCatalog(database, sourceRevision, createdAt); |
| 1681 | + insertJourneys(database); | |
| 1477 | 1682 | dropStaging(database); |
| 1478 | 1683 | const result = validateOpenCatalog(database); |
| 1479 | 1684 | 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 UNIQUE | 511 | 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 NULL | 580 | + 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_id | 1070 | 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 AS | 1076 | CREATE TABLE stage_terminals AS |
| 1031 | SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name | 1077 | SELECT stop_time.trip_id, stop_time.stop_id, stop.stop_name |
| 1032 | FROM stage_stop_times AS stop_time | 1078 | 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 AS | 1100 | 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 AS | 1162 | CREATE TABLE stage_routing_collisions AS |
| 1103 | SELECT monitoring_ref, id_line, destination_ref | 1163 | 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 places | 1459 | 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 place | 1517 | 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) { | ||
| 46 | 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 | 76 | function hello(message) { |
| 50 | 77 | var decoded = codec.decodeHello(message); |
| 51 | 78 | if (decoded === null) return { consumed: false, changed: false }; |
| @@ -198,6 +225,7 @@ function createConfigurationSync(options) { | ||
| 198 | 225 | return { |
| 199 | 226 | binding: binding, ready: ready, hello: hello, synchronize: synchronize, receive: receive, |
| 200 | 227 | resolveDataBinding: resolveDataBinding, pendingDataBinding: pendingDataBinding, |
| 228 | + enrichPhoneFavorites: enrichPhoneFavorites, | |
| 201 | 229 | discardTransaction: discardTransaction, clear: clear |
| 202 | 230 | }; |
| 203 | 231 | } |
| @@ -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: clear | 229 | 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 @@ | ||
| 233 | 233 | "token": "retry", |
| 234 | 234 | "en": "Select: retry.", |
| 235 | 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 | ||
| @@ -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) { | ||
| 71 | 71 | + layout.fixed(palette, 1) + timestamp(view.trafficCheckedAt) + layout.fixed(departures.length, 1); |
| 72 | 72 | for (var i = 0; i < departures.length; i += 1) { |
| 73 | 73 | 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); | |
| 76 | 76 | } |
| 77 | 77 | return record; |
| 78 | 78 | } |
| @@ -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.