feat(companion): modernize settings and introduce app branding #19
Merged@github-anthodev wants to merge
18/feat/companion/modernize-settings-and-branding into developMerged as
2799b0f470a5.16 files changed
modified
README.md +73 -70 | @@ -1,118 +1,121 @@ | ||
| 1 | +<p align="center"> | |
| 2 | + <img src="packages/config-page/assets/lapin-fute-1024.png" alt="Lapin Futé icon" width="96"> | |
| 3 | +</p> | |
| 4 | + | |
| 1 | 5 | <h1 align="center">Lapin Futé</h1> |
| 2 | 6 | |
| 3 | 7 | <p align="center"> |
| 4 | - <strong>Île-de-France departures on Pebble, without background traffic.</strong> | |
| 8 | + <strong>Next departures and line traffic at a glance, on your Pebble.</strong> | |
| 5 | 9 | <br> |
| 6 | - One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary. | |
| 10 | + Keep six favorite stops across Île-de-France and check them without reaching for your phone. | |
| 7 | 11 | </p> |
| 8 | 12 | |
| 9 | 13 | <p align="center"> |
| 10 | - <a href="#about">About</a> | |
| 11 | - · | |
| 12 | - <a href="#architecture">Architecture</a> | |
| 13 | - · | |
| 14 | - <a href="#build-from-source">Build</a> | |
| 15 | - · | |
| 16 | - <a href="#quality-gates">Quality gates</a> | |
| 14 | + <a href="#features">Features</a> | |
| 15 | + · <a href="#compatibility">Compatibility</a> | |
| 16 | + · <a href="#getting-started">Getting started</a> | |
| 17 | + · <a href="#watch-controls">Watch controls</a> | |
| 18 | + · <a href="#privacy">Privacy</a> | |
| 19 | + · <a href="#build-from-source">Build from source</a> | |
| 17 | 20 | </p> |
| 18 | 21 | |
| 19 | 22 | <p align="center"> |
| 20 | 23 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 21 | - <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript"> | |
| 22 | - <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24"> | |
| 24 | + <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus"> | |
| 25 | + <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> | |
| 23 | 26 | <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status"> |
| 24 | 27 | </p> |
| 25 | 28 | |
| 29 | +> [!IMPORTANT] | |
| 30 | +> Lapin Futé is experimental and not published yet. Trying it currently requires building the watchapp and hosting its settings page. Live departures require a personal [PRIM](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification) access token. Emulator checks do not replace physical-watch validation, which remains pending. The project is not affiliated with RATP or Île-de-France Mobilités. | |
| 31 | + | |
| 26 | 32 | ## About |
| 27 | 33 | |
| 28 | -Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback. | |
| 34 | +Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, 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. | |
| 29 | 35 | |
| 30 | -```text | |
| 31 | -Departure and traffic views | |
| 32 | - │ symbolic AppMessage aliases | |
| 33 | - ▼ | |
| 34 | -PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM | |
| 35 | - │ | |
| 36 | - └── phone-local favorites (six) + personal key | |
| 37 | -``` | |
| 36 | +The name nods to Bison Futé, the French road-traffic information service, and to Serge, the rabbit from RATP's safety campaigns. On the icon, the rabbit's outline traces an itinerary that ends at two round stops. | |
| 37 | + | |
| 38 | +## Features | |
| 38 | 39 | |
| 39 | -**The watch never receives the personal PRIM key and never starts network or background requests.** | |
| 40 | +- **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. | |
| 41 | +- **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes. | |
| 42 | +- **Traffic view.** Select again on a departure board to page through the current service messages for that line. | |
| 43 | +- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling. | |
| 44 | +- **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data. | |
| 45 | +- **English and French.** The watch interface and the settings page are both bilingual. | |
| 46 | +- **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. | |
| 40 | 47 | |
| 41 | -## Highlights | |
| 48 | +## Compatibility | |
| 42 | 49 | |
| 43 | -- **One watch runtime.** Alloy/XS owns the receiver state machine, committed configuration storage, and the Piu interface that renders prepared favorite, departure, and traffic records. The embedded sources `main`, `runtime`, `packed`, `storage`, and `ui` bundle with the generated copy into the single module the manifest declares. The only C file is the standard `mdbl.c` VM bootstrap. | |
| 44 | -- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` and `packages/contracts/src/index.ts` map exactly 19 aliases to the frozen IDs `0, 1, 2, 3, 10, 11, 12, 24, 35..45`, and the retired v1 IDs are never reused. Display dictionaries carry `DISPLAY_WIRE_VERSION` 2 while domain data stays at schema version 1; the SDK transport key 15025 remains outside the D2 key table. | |
| 45 | -- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched warm sequence cannot replace the last complete state. FULL is the deliberate exception: once the watch confirms the complete inventory and crosses the guarded durable seam, its previous configuration is already cleared, so a failed FULL leaves the watch unconfigured instead of restoring old metadata. | |
| 46 | -- **Event-only requests.** The watch never polls the network or schedules periodic requests. The phone fetches only when the app opens, the Refresh all row is used, a detail-view favorite change settles for 500 ms, or long Select explicitly refreshes the active favorite, each stale request gated by the 60-second cache. Trigger 5 is strictly cache-only: its overview and detail answers come from the phone cache with no network request and no refreshing echo, and a missing entry reports no cached data instead of fetching. A changed-favorite DIFF that passes the complete hash inventory with a nonzero need mask clears the watch's results and shows the loading state before any body transfers; after the commit the watch re-acquires its results from the cache only. The FULL mode is the explicit destructive resync. | |
| 47 | -- **Phone-prepared layout.** The phone runtime measures, wraps, clips, and packs each appearance and traffic record against pinned firmware font metrics, and the watch verifies the appearance hash before admitting it. The fixed bilingual copy corpus is generated at build time, and the watch assembles countdown and clock values locally, so it renders prepared text without any text fitting. Two display profiles exist, emery at 200×228 rectangular and gabbro at 260×260 round, chosen once at startup. | |
| 48 | -- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, direct PRIM requests, configuration storage, the active-watch language, and the whole display seam: font metrics, bilingual copy corpus, record preparation, and the UTF-8 forward and reverse hash. The exact personal-key boundary is unchanged. | |
| 50 | +| Requirement | Details | | |
| 51 | +|---|---| | |
| 52 | +| Watches | Pebble Time 2 (emery profile, primary target); the gabbro round profile is experimental | | |
| 53 | +| Firmware | 4.32 or newer | | |
| 54 | +| Phone | Pebble mobile app running the companion; internet access needed for fresh data | | |
| 55 | +| Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 | | |
| 49 | 56 | |
| 50 | -## Architecture | |
| 57 | +## Getting started | |
| 51 | 58 | |
| 52 | -| Component | Owns | Must not | | |
| 53 | -|---|---|---| | |
| 54 | -| Static catalog | Offline-generated IDFM import, validated SQLite candidate, published JSON (manifest, search buckets, services, place pages) beside the config page | contain credentials or proxy live PRIM traffic | | |
| 55 | -| Configuration page | Statically hosted favorite editing with the six-favorite cap, masked key replacement/removal, static catalog place/service lookup | receive the stored key when reopening | | |
| 56 | -| PebbleKit JS | One atomic phone-local record, direct PRIM HTTPS requests, cache, watch synchronization, display preparation and packing, active-watch language | expose the key over AppMessage | | |
| 57 | -| Alloy watchapp | Prepared-record rendering with local navigation, committed configuration storage, loading and error states, data requests to the phone | PRIM networking or credentials; WakeUp, Worker, timeline, touch polling, text fitting, or secret storage | | |
| 59 | +There is no published package yet, so first use means one build and one static host: | |
| 58 | 60 | |
| 59 | -The embedded sources are `main`, `runtime`, `packed`, `storage`, and `ui` under `packages/watch/src/embeddedjs/`, plus the generated copy module `packages/watch/src/generated/display-copy.js`; Bun bundles them into the one module the manifest declares. | |
| 61 | +1. Build the watchapp from source (see [Build from source](#build-from-source)); the built package is written under `packages/watch/build/`. | |
| 62 | +2. Generate the station catalog and build the settings page using the commands below. `npm run build:config-site` assembles the site in `var/config-site`. Publish that folder on a static HTTPS host, then build the watchapp with `LAPIN_FUTE_CONFIG_URL=https://your-host/` so the phone opens your settings page. | |
| 63 | +3. Install the built package on your watch with the Pebble tooling, or try it first in the emery or gabbro emulator. | |
| 64 | +4. 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 | +5. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch. | |
| 66 | +6. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data. | |
| 60 | 67 | |
| 61 | -## Targets and requirements | |
| 68 | +## Watch controls | |
| 62 | 69 | |
| 63 | -| Layer | Pinned requirement | | |
| 70 | +| Screen | Buttons | | |
| 64 | 71 | |---|---| |
| 65 | -| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator | | |
| 66 | -| Firmware | 4.32 or newer | | |
| 67 | -| Pebble SDK | 4.33.1 | | |
| 68 | -| Pebble CLI | `pebble-tool` 5.0.40 | | |
| 69 | -| Host runtime | Node.js 24.18.0 | | |
| 70 | -| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media | | |
| 72 | +| Favorites list | Up/Down move through favorites and the Refresh all row; Select opens the highlighted favorite, or refreshes everything from the Refresh all row | | |
| 73 | +| Departures | Up/Down switch favorite; Select opens the traffic view; holding Select forces a refresh | | |
| 74 | +| Traffic | Up/Down page through messages | | |
| 75 | +| Anywhere | Back steps back to the previous screen | | |
| 76 | + | |
| 77 | +Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests. | |
| 71 | 78 | |
| 79 | +## Privacy | |
| 80 | + | |
| 81 | +Your PRIM token stays on your phone. It is stored locally and sent only as the request header of the companion's direct HTTPS calls to PRIM, which is operated by Île-de-France Mobilités. It never reaches the watch, your favorites, cached data, URLs, or logs. Browsing the settings page and its station catalog sends no credentials. The app has no telemetry, no analytics, and no background or periodic network activity. | |
| 72 | 82 | |
| 73 | 83 | ## Build from source |
| 74 | 84 | |
| 75 | -Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain. | |
| 85 | +Install the pinned prerequisites from the table above; no repository script installs a toolchain for you. | |
| 76 | 86 | |
| 77 | 87 | ```sh |
| 78 | 88 | npm test |
| 79 | -npm run measure:radio | |
| 80 | 89 | npm run build |
| 81 | 90 | ``` |
| 82 | 91 | |
| 83 | -`npm run build` runs the host suite, which generates the display copy first, then measures the modeled D2 radio exchanges, bundles the watch sources with Bun into one generated module, generates the PebbleKit JS tree, builds the single Alloy package, and verifies the generated alias-to-ID map. The watch build needs no catalog and no network. The one optional build input is the credential-free HTTPS URL of the statically hosted configuration page: | |
| 92 | +`npm run build` runs the test suite, generates the bilingual display copy, bundles the watchapp for emery and gabbro, and verifies the result. The watch build needs no catalog and no network. Its one optional input is the settings-page URL: | |
| 84 | 93 | |
| 85 | 94 | ```sh |
| 86 | -LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ | |
| 87 | -npm run build | |
| 95 | +LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build | |
| 88 | 96 | ``` |
| 89 | 97 | |
| 90 | -Personal PRIM keys are never build inputs. | |
| 91 | - | |
| 92 | -The catalog toolchain (`packages/catalog`) is offline except for its operator-driven refresh. With `IDFM_DATASET_TOKEN` in `.env`, `node --env-file=.env scripts/refresh-catalog.mjs` downloads the IDFM sources, builds and validates the SQLite catalog, and publishes the static JSON catalog to `var/catalog/static`. `npm run build:config-site` then assembles the deployable site in `var/config-site`: the config page with the published catalog under `catalog/` beside `index.html`. It fails explicitly when no real catalog was published; there is no fixture fallback. `npm run catalog:probe` performs the operator-run live PRIM probe using `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed, exported, or copied into build output. | |
| 93 | - | |
| 94 | -## Privacy | |
| 95 | - | |
| 96 | -The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, then the exact PRIM `apikey` header on the companion's direct HTTPS request. Static catalog and configuration-page requests carry no credential. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence. | |
| 98 | +The station catalog is generated ahead of time from open IDFM datasets. With `IDFM_DATASET_TOKEN` in `.env`: | |
| 97 | 99 | |
| 98 | -Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. | |
| 100 | +```sh | |
| 101 | +node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog | |
| 102 | +npm run build:config-site # assemble var/config-site; fails without a published catalog | |
| 103 | +``` | |
| 99 | 104 | |
| 100 | -## Quality gates | |
| 105 | +`node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. | |
| 101 | 106 | |
| 102 | -| Gate | Budget | | |
| 103 | -|---|---| | |
| 104 | -| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 | | |
| 105 | -| Phone-to-watch dictionary | At most 768 encoded bytes | | |
| 106 | -| Watch-to-phone dictionary | At most 192 encoded bytes | | |
| 107 | -| Watch unsent queue | Four messages, at most once, one write per `onWritable` | | |
| 108 | -| Full six-favorite configuration | 15 dictionaries, modeled host bound | | |
| 109 | -| Initial D2 startup | 18 D2 dictionary attempts, modeled host bound, excluding the SDK transport; one conditional SDK announcement/echo pair adds 2 | | |
| 110 | -| SDK transport key | 15025 belongs to the SDK transport, never to a D2 message | | |
| 107 | +## Icon | |
| 111 | 108 | |
| 112 | -Validation status: host suites, the modeled radio gate, and release builds for both targets pass. The current production PBW has run on the Emery and Gabbro emulators against recorded fixtures with zero live PRIM calls: startup, six-favorite synchronization, detail and traffic views, unchanged and reordered DIFFs, a changed-favorite DIFF with its loading state and cache-only recovery, and FULL resync with manual recovery all executed. | |
| 109 | +The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`: | |
| 113 | 110 | |
| 114 | -Retained reopen and ten minutes of inactivity pass on both emulators; the idle windows emitted no AppMessages or additional fixture requests. With the fixed VM split, the bounded six-favorite corpus left at least 10% of the chunk arena free at measured natural compacted-live samples on both targets. These samples do not certify every transient or lifetime memory peak. Physical-device validation and the 24-hour battery gate remain open. This is not a release. | |
| 111 | +| File | Format | Background | | |
| 112 | +|---|---|---| | |
| 113 | +| [`lapin-fute.svg`](packages/config-page/assets/lapin-fute.svg) | Vector source | Transparent | | |
| 114 | +| [`lapin-fute-1024.png`](packages/config-page/assets/lapin-fute-1024.png) | PNG, 1024 × 1024 | Transparent | | |
| 115 | +| [`lapin-fute-1024.jpg`](packages/config-page/assets/lapin-fute-1024.jpg) | JPEG, 1024 × 1024 | White | | |
| 115 | 116 | |
| 116 | 117 | ## Contributing |
| 117 | 118 | |
| 118 | -Keep the frozen contracts, symbolic runtime payloads, event-only policy, and secret boundary intact: domain data stays at schema version 1, display dictionaries at wire version 2, and `packages/watch/package.json` pins the alias-to-ID map next to the canonical contract in `packages/contracts/src/index.ts`. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests. | |
| 119 | +Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue. | |
| 120 | + | |
| 121 | +Code changes should keep the frozen message contracts and the phone-only credential boundary intact. | |
| @@ -1,118 +1,121 @@ | |||
| 1 | +<p align="center"> | ||
| 2 | + <img src="packages/config-page/assets/lapin-fute-1024.png" alt="Lapin Futé icon" width="96"> | ||
| 3 | +</p> | ||
| 4 | + | ||
| 1 | <h1 align="center">Lapin Futé</h1> | 5 | <h1 align="center">Lapin Futé</h1> |
| 2 | 6 | ||
| 3 | <p align="center"> | 7 | <p align="center"> |
| 4 | - <strong>Île-de-France departures on Pebble, without background traffic.</strong> | 8 | + <strong>Next departures and line traffic at a glance, on your Pebble.</strong> |
| 5 | <br> | 9 | <br> |
| 6 | - One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary. | 10 | + Keep six favorite stops across Île-de-France and check them without reaching for your phone. |
| 7 | </p> | 11 | </p> |
| 8 | 12 | ||
| 9 | <p align="center"> | 13 | <p align="center"> |
| 10 | - <a href="#about">About</a> | 14 | + <a href="#features">Features</a> |
| 11 | - · | 15 | + · <a href="#compatibility">Compatibility</a> |
| 12 | - <a href="#architecture">Architecture</a> | 16 | + · <a href="#getting-started">Getting started</a> |
| 13 | - · | 17 | + · <a href="#watch-controls">Watch controls</a> |
| 14 | - <a href="#build-from-source">Build</a> | 18 | + · <a href="#privacy">Privacy</a> |
| 15 | - · | 19 | + · <a href="#build-from-source">Build from source</a> |
| 16 | - <a href="#quality-gates">Quality gates</a> | ||
| 17 | </p> | 20 | </p> |
| 18 | 21 | ||
| 19 | <p align="center"> | 22 | <p align="center"> |
| 20 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> | 23 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 21 | - <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript"> | 24 | + <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus"> |
| 22 | - <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24"> | 25 | + <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> |
| 23 | <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status"> | 26 | <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status"> |
| 24 | </p> | 27 | </p> |
| 25 | 28 | ||
| 29 | +> [!IMPORTANT] | ||
| 30 | +> Lapin Futé is experimental and not published yet. Trying it currently requires building the watchapp and hosting its settings page. Live departures require a personal [PRIM](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification) access token. Emulator checks do not replace physical-watch validation, which remains pending. The project is not affiliated with RATP or Île-de-France Mobilités. | ||
| 31 | + | ||
| 26 | ## About | 32 | ## About |
| 27 | 33 | ||
| 28 | -Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback. | 34 | +Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, 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. |
| 29 | 35 | ||
| 30 | -```text | 36 | +The name nods to Bison Futé, the French road-traffic information service, and to Serge, the rabbit from RATP's safety campaigns. On the icon, the rabbit's outline traces an itinerary that ends at two round stops. |
| 31 | -Departure and traffic views | 37 | + |
| 32 | - │ symbolic AppMessage aliases | 38 | +## Features |
| 33 | - ▼ | ||
| 34 | -PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM | ||
| 35 | - │ | ||
| 36 | - └── phone-local favorites (six) + personal key | ||
| 37 | -``` | ||
| 38 | 39 | ||
| 39 | -**The watch never receives the personal PRIM key and never starts network or background requests.** | 40 | +- **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. |
| 41 | +- **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes. | ||
| 42 | +- **Traffic view.** Select again on a departure board to page through the current service messages for that line. | ||
| 43 | +- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling. | ||
| 44 | +- **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data. | ||
| 45 | +- **English and French.** The watch interface and the settings page are both bilingual. | ||
| 46 | +- **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. | ||
| 40 | 47 | ||
| 41 | -## Highlights | 48 | +## Compatibility |
| 42 | 49 | ||
| 43 | -- **One watch runtime.** Alloy/XS owns the receiver state machine, committed configuration storage, and the Piu interface that renders prepared favorite, departure, and traffic records. The embedded sources `main`, `runtime`, `packed`, `storage`, and `ui` bundle with the generated copy into the single module the manifest declares. The only C file is the standard `mdbl.c` VM bootstrap. | 50 | +| Requirement | Details | |
| 44 | -- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` and `packages/contracts/src/index.ts` map exactly 19 aliases to the frozen IDs `0, 1, 2, 3, 10, 11, 12, 24, 35..45`, and the retired v1 IDs are never reused. Display dictionaries carry `DISPLAY_WIRE_VERSION` 2 while domain data stays at schema version 1; the SDK transport key 15025 remains outside the D2 key table. | 51 | +|---|---| |
| 45 | -- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched warm sequence cannot replace the last complete state. FULL is the deliberate exception: once the watch confirms the complete inventory and crosses the guarded durable seam, its previous configuration is already cleared, so a failed FULL leaves the watch unconfigured instead of restoring old metadata. | 52 | +| Watches | Pebble Time 2 (emery profile, primary target); the gabbro round profile is experimental | |
| 46 | -- **Event-only requests.** The watch never polls the network or schedules periodic requests. The phone fetches only when the app opens, the Refresh all row is used, a detail-view favorite change settles for 500 ms, or long Select explicitly refreshes the active favorite, each stale request gated by the 60-second cache. Trigger 5 is strictly cache-only: its overview and detail answers come from the phone cache with no network request and no refreshing echo, and a missing entry reports no cached data instead of fetching. A changed-favorite DIFF that passes the complete hash inventory with a nonzero need mask clears the watch's results and shows the loading state before any body transfers; after the commit the watch re-acquires its results from the cache only. The FULL mode is the explicit destructive resync. | 53 | +| Firmware | 4.32 or newer | |
| 47 | -- **Phone-prepared layout.** The phone runtime measures, wraps, clips, and packs each appearance and traffic record against pinned firmware font metrics, and the watch verifies the appearance hash before admitting it. The fixed bilingual copy corpus is generated at build time, and the watch assembles countdown and clock values locally, so it renders prepared text without any text fitting. Two display profiles exist, emery at 200×228 rectangular and gabbro at 260×260 round, chosen once at startup. | 54 | +| Phone | Pebble mobile app running the companion; internet access needed for fresh data | |
| 48 | -- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, direct PRIM requests, configuration storage, the active-watch language, and the whole display seam: font metrics, bilingual copy corpus, record preparation, and the UTF-8 forward and reverse hash. The exact personal-key boundary is unchanged. | 55 | +| Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 | |
| 49 | 56 | ||
| 50 | -## Architecture | 57 | +## Getting started |
| 51 | 58 | ||
| 52 | -| Component | Owns | Must not | | 59 | +There is no published package yet, so first use means one build and one static host: |
| 53 | -|---|---|---| | ||
| 54 | -| Static catalog | Offline-generated IDFM import, validated SQLite candidate, published JSON (manifest, search buckets, services, place pages) beside the config page | contain credentials or proxy live PRIM traffic | | ||
| 55 | -| Configuration page | Statically hosted favorite editing with the six-favorite cap, masked key replacement/removal, static catalog place/service lookup | receive the stored key when reopening | | ||
| 56 | -| PebbleKit JS | One atomic phone-local record, direct PRIM HTTPS requests, cache, watch synchronization, display preparation and packing, active-watch language | expose the key over AppMessage | | ||
| 57 | -| Alloy watchapp | Prepared-record rendering with local navigation, committed configuration storage, loading and error states, data requests to the phone | PRIM networking or credentials; WakeUp, Worker, timeline, touch polling, text fitting, or secret storage | | ||
| 58 | 60 | ||
| 59 | -The embedded sources are `main`, `runtime`, `packed`, `storage`, and `ui` under `packages/watch/src/embeddedjs/`, plus the generated copy module `packages/watch/src/generated/display-copy.js`; Bun bundles them into the one module the manifest declares. | 61 | +1. Build the watchapp from source (see [Build from source](#build-from-source)); the built package is written under `packages/watch/build/`. |
| 62 | +2. Generate the station catalog and build the settings page using the commands below. `npm run build:config-site` assembles the site in `var/config-site`. Publish that folder on a static HTTPS host, then build the watchapp with `LAPIN_FUTE_CONFIG_URL=https://your-host/` so the phone opens your settings page. | ||
| 63 | +3. Install the built package on your watch with the Pebble tooling, or try it first in the emery or gabbro emulator. | ||
| 64 | +4. 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 | +5. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch. | ||
| 66 | +6. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data. | ||
| 60 | 67 | ||
| 61 | -## Targets and requirements | 68 | +## Watch controls |
| 62 | 69 | ||
| 63 | -| Layer | Pinned requirement | | 70 | +| Screen | Buttons | |
| 64 | |---|---| | 71 | |---|---| |
| 65 | -| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator | | 72 | +| Favorites list | Up/Down move through favorites and the Refresh all row; Select opens the highlighted favorite, or refreshes everything from the Refresh all row | |
| 66 | -| Firmware | 4.32 or newer | | 73 | +| Departures | Up/Down switch favorite; Select opens the traffic view; holding Select forces a refresh | |
| 67 | -| Pebble SDK | 4.33.1 | | 74 | +| Traffic | Up/Down page through messages | |
| 68 | -| Pebble CLI | `pebble-tool` 5.0.40 | | 75 | +| Anywhere | Back steps back to the previous screen | |
| 69 | -| Host runtime | Node.js 24.18.0 | | 76 | + |
| 70 | -| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media | | 77 | +Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests. |
| 71 | 78 | ||
| 79 | +## Privacy | ||
| 80 | + | ||
| 81 | +Your PRIM token stays on your phone. It is stored locally and sent only as the request header of the companion's direct HTTPS calls to PRIM, which is operated by Île-de-France Mobilités. It never reaches the watch, your favorites, cached data, URLs, or logs. Browsing the settings page and its station catalog sends no credentials. The app has no telemetry, no analytics, and no background or periodic network activity. | ||
| 72 | 82 | ||
| 73 | ## Build from source | 83 | ## Build from source |
| 74 | 84 | ||
| 75 | -Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain. | 85 | +Install the pinned prerequisites from the table above; no repository script installs a toolchain for you. |
| 76 | 86 | ||
| 77 | ```sh | 87 | ```sh |
| 78 | npm test | 88 | npm test |
| 79 | -npm run measure:radio | ||
| 80 | npm run build | 89 | npm run build |
| 81 | ``` | 90 | ``` |
| 82 | 91 | ||
| 83 | -`npm run build` runs the host suite, which generates the display copy first, then measures the modeled D2 radio exchanges, bundles the watch sources with Bun into one generated module, generates the PebbleKit JS tree, builds the single Alloy package, and verifies the generated alias-to-ID map. The watch build needs no catalog and no network. The one optional build input is the credential-free HTTPS URL of the statically hosted configuration page: | 92 | +`npm run build` runs the test suite, generates the bilingual display copy, bundles the watchapp for emery and gabbro, and verifies the result. The watch build needs no catalog and no network. Its one optional input is the settings-page URL: |
| 84 | 93 | ||
| 85 | ```sh | 94 | ```sh |
| 86 | -LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ | 95 | +LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build |
| 87 | -npm run build | ||
| 88 | ``` | 96 | ``` |
| 89 | 97 | ||
| 90 | -Personal PRIM keys are never build inputs. | 98 | +The station catalog is generated ahead of time from open IDFM datasets. With `IDFM_DATASET_TOKEN` in `.env`: |
| 91 | - | ||
| 92 | -The catalog toolchain (`packages/catalog`) is offline except for its operator-driven refresh. With `IDFM_DATASET_TOKEN` in `.env`, `node --env-file=.env scripts/refresh-catalog.mjs` downloads the IDFM sources, builds and validates the SQLite catalog, and publishes the static JSON catalog to `var/catalog/static`. `npm run build:config-site` then assembles the deployable site in `var/config-site`: the config page with the published catalog under `catalog/` beside `index.html`. It fails explicitly when no real catalog was published; there is no fixture fallback. `npm run catalog:probe` performs the operator-run live PRIM probe using `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed, exported, or copied into build output. | ||
| 93 | - | ||
| 94 | -## Privacy | ||
| 95 | - | ||
| 96 | -The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, then the exact PRIM `apikey` header on the companion's direct HTTPS request. Static catalog and configuration-page requests carry no credential. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence. | ||
| 97 | 99 | ||
| 98 | -Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. | 100 | +```sh |
| 101 | +node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog | ||
| 102 | +npm run build:config-site # assemble var/config-site; fails without a published catalog | ||
| 103 | +``` | ||
| 99 | 104 | ||
| 100 | -## Quality gates | 105 | +`node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. |
| 101 | 106 | ||
| 102 | -| Gate | Budget | | 107 | +## Icon |
| 103 | -|---|---| | ||
| 104 | -| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 | | ||
| 105 | -| Phone-to-watch dictionary | At most 768 encoded bytes | | ||
| 106 | -| Watch-to-phone dictionary | At most 192 encoded bytes | | ||
| 107 | -| Watch unsent queue | Four messages, at most once, one write per `onWritable` | | ||
| 108 | -| Full six-favorite configuration | 15 dictionaries, modeled host bound | | ||
| 109 | -| Initial D2 startup | 18 D2 dictionary attempts, modeled host bound, excluding the SDK transport; one conditional SDK announcement/echo pair adds 2 | | ||
| 110 | -| SDK transport key | 15025 belongs to the SDK transport, never to a D2 message | | ||
| 111 | 108 | ||
| 112 | -Validation status: host suites, the modeled radio gate, and release builds for both targets pass. The current production PBW has run on the Emery and Gabbro emulators against recorded fixtures with zero live PRIM calls: startup, six-favorite synchronization, detail and traffic views, unchanged and reordered DIFFs, a changed-favorite DIFF with its loading state and cache-only recovery, and FULL resync with manual recovery all executed. | 109 | +The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`: |
| 113 | 110 | ||
| 114 | -Retained reopen and ten minutes of inactivity pass on both emulators; the idle windows emitted no AppMessages or additional fixture requests. With the fixed VM split, the bounded six-favorite corpus left at least 10% of the chunk arena free at measured natural compacted-live samples on both targets. These samples do not certify every transient or lifetime memory peak. Physical-device validation and the 24-hour battery gate remain open. This is not a release. | 111 | +| File | Format | Background | |
| 112 | +|---|---|---| | ||
| 113 | +| [`lapin-fute.svg`](packages/config-page/assets/lapin-fute.svg) | Vector source | Transparent | | ||
| 114 | +| [`lapin-fute-1024.png`](packages/config-page/assets/lapin-fute-1024.png) | PNG, 1024 × 1024 | Transparent | | ||
| 115 | +| [`lapin-fute-1024.jpg`](packages/config-page/assets/lapin-fute-1024.jpg) | JPEG, 1024 × 1024 | White | | ||
| 115 | 116 | ||
| 116 | ## Contributing | 117 | ## Contributing |
| 117 | 118 | ||
| 118 | -Keep the frozen contracts, symbolic runtime payloads, event-only policy, and secret boundary intact: domain data stays at schema version 1, display dictionaries at wire version 2, and `packages/watch/package.json` pins the alias-to-ID map next to the canonical contract in `packages/contracts/src/index.ts`. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests. | 119 | +Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue. |
| 120 | + | ||
| 121 | +Code changes should keep the frozen message contracts and the phone-only credential boundary intact. | ||
modified
packages/catalog/src/catalog.ts +50 -12 | @@ -2,6 +2,8 @@ import { DatabaseSync, type StatementSync } from "node:sqlite"; | ||
| 2 | 2 | import { |
| 3 | 3 | LIMITS, |
| 4 | 4 | TRANSPORT_MODE, |
| 5 | + isPlaceLine, | |
| 6 | + type PlaceLine, | |
| 5 | 7 | type PlaceSearchItem, |
| 6 | 8 | type ServiceOption, |
| 7 | 9 | type TransportMode, |
| @@ -30,6 +32,7 @@ interface PlaceRow { | ||
| 30 | 32 | readonly stopLabel: string; |
| 31 | 33 | readonly localityLabel: string | null; |
| 32 | 34 | readonly mode: TransportMode; |
| 35 | + readonly lines: string; | |
| 33 | 36 | } |
| 34 | 37 | |
| 35 | 38 | interface ServiceRow { |
| @@ -446,11 +449,40 @@ function normalizedMatchQuery(query: string): string { | ||
| 446 | 449 | .join(" AND "); |
| 447 | 450 | } |
| 448 | 451 | |
| 452 | +// Keep native identity until display ordering and duplicate checks are complete. | |
| 453 | +export const PLACE_LINES_SQL = `json_group_array(DISTINCT json_object( | |
| 454 | + 'lineRef', services.line_ref, | |
| 455 | + 'lineLabel', services.line_label, | |
| 456 | + 'lineColor', services.line_color, | |
| 457 | + 'lineTextColor', services.line_text_color | |
| 458 | +))`; | |
| 459 | +const lineLabelOrder = new Intl.Collator("fr", { numeric: true, sensitivity: "base" }); | |
| 460 | + | |
| 461 | +export function placeLinesFromJson(serialized: string): PlaceLine[] { | |
| 462 | + const entries = JSON.parse(serialized) as (PlaceLine & { readonly lineRef: string })[]; | |
| 463 | + entries.sort((left, right) => lineLabelOrder.compare(left.lineLabel, right.lineLabel) | |
| 464 | + || (left.lineRef < right.lineRef ? -1 : left.lineRef > right.lineRef ? 1 : 0)); | |
| 465 | + const identities = new Set<string>(); | |
| 466 | + return entries.map((entry) => { | |
| 467 | + // DISTINCT removed destination copies. A remaining duplicate ref is conflicting metadata. | |
| 468 | + if (identities.has(entry.lineRef)) invalidCandidate("inconsistent line metadata"); | |
| 469 | + identities.add(entry.lineRef); | |
| 470 | + const line = { | |
| 471 | + lineLabel: entry.lineLabel, | |
| 472 | + lineColor: entry.lineColor, | |
| 473 | + lineTextColor: entry.lineTextColor, | |
| 474 | + }; | |
| 475 | + if (!isPlaceLine(line)) invalidCandidate("line metadata"); | |
| 476 | + return line; | |
| 477 | + }); | |
| 478 | +} | |
| 479 | + | |
| 449 | 480 | function placeItem(entry: PlaceRow): PlaceSearchItem { |
| 450 | 481 | const item: PlaceSearchItem = { |
| 451 | 482 | placeId: entry.placeId, |
| 452 | 483 | stopLabel: entry.stopLabel, |
| 453 | 484 | mode: entry.mode, |
| 485 | + lines: placeLinesFromJson(entry.lines), | |
| 454 | 486 | }; |
| 455 | 487 | if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel; |
| 456 | 488 | return item; |
| @@ -467,19 +499,25 @@ export class SqliteCatalogReader implements CatalogReader { | ||
| 467 | 499 | private constructor(database: DatabaseSync) { |
| 468 | 500 | this.#database = database; |
| 469 | 501 | this.#searchPlacesStatement = database.prepare(` |
| 502 | + WITH matches AS MATERIALIZED ( | |
| 503 | + SELECT places.place_id, places.stop_label, places.locality_label, places.mode, | |
| 504 | + bm25(place_search) AS search_rank | |
| 505 | + FROM place_search | |
| 506 | + JOIN places ON places.place_id = place_search.place_id | |
| 507 | + WHERE place_search MATCH ? | |
| 508 | + ORDER BY search_rank, places.stop_label COLLATE NOCASE, places.place_id | |
| 509 | + LIMIT ? | |
| 510 | + ) | |
| 470 | 511 | SELECT |
| 471 | - places.place_id AS placeId, | |
| 472 | - places.stop_label AS stopLabel, | |
| 473 | - places.locality_label AS localityLabel, | |
| 474 | - places.mode AS mode | |
| 475 | - FROM place_search | |
| 476 | - JOIN places ON places.place_id = place_search.place_id | |
| 477 | - WHERE place_search MATCH ? | |
| 478 | - ORDER BY | |
| 479 | - bm25(place_search), | |
| 480 | - places.stop_label COLLATE NOCASE, | |
| 481 | - places.place_id | |
| 482 | - LIMIT ? | |
| 512 | + matches.place_id AS placeId, | |
| 513 | + matches.stop_label AS stopLabel, | |
| 514 | + matches.locality_label AS localityLabel, | |
| 515 | + matches.mode AS mode, | |
| 516 | + ${PLACE_LINES_SQL} AS lines | |
| 517 | + FROM matches | |
| 518 | + JOIN services ON services.place_id = matches.place_id | |
| 519 | + GROUP BY matches.place_id | |
| 520 | + ORDER BY matches.search_rank, matches.stop_label COLLATE NOCASE, matches.place_id | |
| 483 | 521 | `); |
| 484 | 522 | this.#placeExistsStatement = database.prepare(` |
| 485 | 523 | SELECT 1 |
| @@ -2,6 +2,8 @@ import { DatabaseSync, type StatementSync } from "node:sqlite"; | |||
| 2 | import { | 2 | import { |
| 3 | LIMITS, | 3 | LIMITS, |
| 4 | TRANSPORT_MODE, | 4 | TRANSPORT_MODE, |
| 5 | + isPlaceLine, | ||
| 6 | + type PlaceLine, | ||
| 5 | type PlaceSearchItem, | 7 | type PlaceSearchItem, |
| 6 | type ServiceOption, | 8 | type ServiceOption, |
| 7 | type TransportMode, | 9 | type TransportMode, |
| @@ -30,6 +32,7 @@ interface PlaceRow { | |||
| 30 | readonly stopLabel: string; | 32 | readonly stopLabel: string; |
| 31 | readonly localityLabel: string | null; | 33 | readonly localityLabel: string | null; |
| 32 | readonly mode: TransportMode; | 34 | readonly mode: TransportMode; |
| 35 | + readonly lines: string; | ||
| 33 | } | 36 | } |
| 34 | 37 | ||
| 35 | interface ServiceRow { | 38 | interface ServiceRow { |
| @@ -446,11 +449,40 @@ function normalizedMatchQuery(query: string): string { | |||
| 446 | .join(" AND "); | 449 | .join(" AND "); |
| 447 | } | 450 | } |
| 448 | 451 | ||
| 452 | +// Keep native identity until display ordering and duplicate checks are complete. | ||
| 453 | +export const PLACE_LINES_SQL = `json_group_array(DISTINCT json_object( | ||
| 454 | + 'lineRef', services.line_ref, | ||
| 455 | + 'lineLabel', services.line_label, | ||
| 456 | + 'lineColor', services.line_color, | ||
| 457 | + 'lineTextColor', services.line_text_color | ||
| 458 | +))`; | ||
| 459 | +const lineLabelOrder = new Intl.Collator("fr", { numeric: true, sensitivity: "base" }); | ||
| 460 | + | ||
| 461 | +export function placeLinesFromJson(serialized: string): PlaceLine[] { | ||
| 462 | + const entries = JSON.parse(serialized) as (PlaceLine & { readonly lineRef: string })[]; | ||
| 463 | + entries.sort((left, right) => lineLabelOrder.compare(left.lineLabel, right.lineLabel) | ||
| 464 | + || (left.lineRef < right.lineRef ? -1 : left.lineRef > right.lineRef ? 1 : 0)); | ||
| 465 | + const identities = new Set<string>(); | ||
| 466 | + return entries.map((entry) => { | ||
| 467 | + // DISTINCT removed destination copies. A remaining duplicate ref is conflicting metadata. | ||
| 468 | + if (identities.has(entry.lineRef)) invalidCandidate("inconsistent line metadata"); | ||
| 469 | + identities.add(entry.lineRef); | ||
| 470 | + const line = { | ||
| 471 | + lineLabel: entry.lineLabel, | ||
| 472 | + lineColor: entry.lineColor, | ||
| 473 | + lineTextColor: entry.lineTextColor, | ||
| 474 | + }; | ||
| 475 | + if (!isPlaceLine(line)) invalidCandidate("line metadata"); | ||
| 476 | + return line; | ||
| 477 | + }); | ||
| 478 | +} | ||
| 479 | + | ||
| 449 | function placeItem(entry: PlaceRow): PlaceSearchItem { | 480 | function placeItem(entry: PlaceRow): PlaceSearchItem { |
| 450 | const item: PlaceSearchItem = { | 481 | const item: PlaceSearchItem = { |
| 451 | placeId: entry.placeId, | 482 | placeId: entry.placeId, |
| 452 | stopLabel: entry.stopLabel, | 483 | stopLabel: entry.stopLabel, |
| 453 | mode: entry.mode, | 484 | mode: entry.mode, |
| 485 | + lines: placeLinesFromJson(entry.lines), | ||
| 454 | }; | 486 | }; |
| 455 | if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel; | 487 | if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel; |
| 456 | return item; | 488 | return item; |
| @@ -467,19 +499,25 @@ export class SqliteCatalogReader implements CatalogReader { | |||
| 467 | private constructor(database: DatabaseSync) { | 499 | private constructor(database: DatabaseSync) { |
| 468 | this.#database = database; | 500 | this.#database = database; |
| 469 | this.#searchPlacesStatement = database.prepare(` | 501 | this.#searchPlacesStatement = database.prepare(` |
| 502 | + WITH matches AS MATERIALIZED ( | ||
| 503 | + SELECT places.place_id, places.stop_label, places.locality_label, places.mode, | ||
| 504 | + bm25(place_search) AS search_rank | ||
| 505 | + FROM place_search | ||
| 506 | + JOIN places ON places.place_id = place_search.place_id | ||
| 507 | + WHERE place_search MATCH ? | ||
| 508 | + ORDER BY search_rank, places.stop_label COLLATE NOCASE, places.place_id | ||
| 509 | + LIMIT ? | ||
| 510 | + ) | ||
| 470 | SELECT | 511 | SELECT |
| 471 | - places.place_id AS placeId, | 512 | + matches.place_id AS placeId, |
| 472 | - places.stop_label AS stopLabel, | 513 | + matches.stop_label AS stopLabel, |
| 473 | - places.locality_label AS localityLabel, | 514 | + matches.locality_label AS localityLabel, |
| 474 | - places.mode AS mode | 515 | + matches.mode AS mode, |
| 475 | - FROM place_search | 516 | + ${PLACE_LINES_SQL} AS lines |
| 476 | - JOIN places ON places.place_id = place_search.place_id | 517 | + FROM matches |
| 477 | - WHERE place_search MATCH ? | 518 | + JOIN services ON services.place_id = matches.place_id |
| 478 | - ORDER BY | 519 | + GROUP BY matches.place_id |
| 479 | - bm25(place_search), | 520 | + ORDER BY matches.search_rank, matches.stop_label COLLATE NOCASE, matches.place_id |
| 480 | - places.stop_label COLLATE NOCASE, | ||
| 481 | - places.place_id | ||
| 482 | - LIMIT ? | ||
| 483 | `); | 521 | `); |
| 484 | this.#placeExistsStatement = database.prepare(` | 522 | this.#placeExistsStatement = database.prepare(` |
| 485 | SELECT 1 | 523 | SELECT 1 |
modified
packages/catalog/src/static-catalog.ts +11 -4 | @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | validateCatalogCandidate, |
| 29 | 29 | type CatalogSourceAttribution, |
| 30 | 30 | } from "./catalog-import.ts"; |
| 31 | +import { PLACE_LINES_SQL, placeLinesFromJson } from "./catalog.ts"; | |
| 31 | 32 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; |
| 32 | 33 | |
| 33 | 34 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u; |
| @@ -72,8 +73,11 @@ interface PageWriter { | ||
| 72 | 73 | const PLACE_QUERY = ` |
| 73 | 74 | SELECT places.place_id AS placeId, places.stop_label AS stopLabel, |
| 74 | 75 | places.locality_label AS localityLabel, places.mode AS mode, |
| 75 | - place_search.search_text AS searchText | |
| 76 | - FROM places JOIN place_search ON place_search.place_id = places.place_id | |
| 76 | + place_search.search_text AS searchText, ${PLACE_LINES_SQL} AS lines | |
| 77 | + FROM places | |
| 78 | + JOIN place_search ON place_search.place_id = places.place_id | |
| 79 | + JOIN services ON services.place_id = places.place_id | |
| 80 | + GROUP BY places.place_id | |
| 77 | 81 | ORDER BY places.place_id |
| 78 | 82 | `; |
| 79 | 83 | const SERVICE_QUERY = ` |
| @@ -115,11 +119,13 @@ function publicAttribution(sources: readonly CatalogSourceAttribution[]): Catalo | ||
| 115 | 119 | } |
| 116 | 120 | |
| 117 | 121 | function searchRow(row: SqlRow): SearchRow { |
| 122 | + if (typeof row.lines !== "string") fail("invalid search row"); | |
| 118 | 123 | const place = { |
| 119 | 124 | placeId: row.placeId, |
| 120 | 125 | stopLabel: row.stopLabel, |
| 121 | 126 | ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }), |
| 122 | 127 | mode: row.mode, |
| 128 | + lines: placeLinesFromJson(row.lines), | |
| 123 | 129 | }; |
| 124 | 130 | if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId) |
| 125 | 131 | || typeof row.searchText !== "string" || row.searchText === "" |
| @@ -209,9 +215,10 @@ export function publishStaticCatalog(options: PublishStaticCatalogOptions): Stat | ||
| 209 | 215 | } |
| 210 | 216 | // Hash the same pinned SQLite snapshot that will be exported, in bounded rows. |
| 211 | 217 | const digest = createHash("sha256").update(EXPORT_FORMAT).update(JSON.stringify({ metadata, attribution })); |
| 212 | - for (const query of [PLACE_QUERY, SERVICE_QUERY]) { | |
| 213 | - for (const row of database.prepare(query).iterate()) digest.update(JSON.stringify(row)).update("\n"); | |
| 218 | + for (const row of database.prepare(PLACE_QUERY).iterate() as Iterable<SqlRow>) { | |
| 219 | + digest.update(JSON.stringify(searchRow(row))).update("\n"); | |
| 214 | 220 | } |
| 221 | + for (const row of database.prepare(SERVICE_QUERY).iterate()) digest.update(JSON.stringify(row)).update("\n"); | |
| 215 | 222 | const revision = digest.digest("hex"); |
| 216 | 223 | const manifest: StaticCatalogManifest = { |
| 217 | 224 | schemaVersion: SCHEMA_VERSION, |
| @@ -28,6 +28,7 @@ import { | |||
| 28 | validateCatalogCandidate, | 28 | validateCatalogCandidate, |
| 29 | type CatalogSourceAttribution, | 29 | type CatalogSourceAttribution, |
| 30 | } from "./catalog-import.ts"; | 30 | } from "./catalog-import.ts"; |
| 31 | +import { PLACE_LINES_SQL, placeLinesFromJson } from "./catalog.ts"; | ||
| 31 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; | 32 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; |
| 32 | 33 | ||
| 33 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u; | 34 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u; |
| @@ -72,8 +73,11 @@ interface PageWriter { | |||
| 72 | const PLACE_QUERY = ` | 73 | const PLACE_QUERY = ` |
| 73 | SELECT places.place_id AS placeId, places.stop_label AS stopLabel, | 74 | SELECT places.place_id AS placeId, places.stop_label AS stopLabel, |
| 74 | places.locality_label AS localityLabel, places.mode AS mode, | 75 | places.locality_label AS localityLabel, places.mode AS mode, |
| 75 | - place_search.search_text AS searchText | 76 | + place_search.search_text AS searchText, ${PLACE_LINES_SQL} AS lines |
| 76 | - FROM places JOIN place_search ON place_search.place_id = places.place_id | 77 | + FROM places |
| 78 | + JOIN place_search ON place_search.place_id = places.place_id | ||
| 79 | + JOIN services ON services.place_id = places.place_id | ||
| 80 | + GROUP BY places.place_id | ||
| 77 | ORDER BY places.place_id | 81 | ORDER BY places.place_id |
| 78 | `; | 82 | `; |
| 79 | const SERVICE_QUERY = ` | 83 | const SERVICE_QUERY = ` |
| @@ -115,11 +119,13 @@ function publicAttribution(sources: readonly CatalogSourceAttribution[]): Catalo | |||
| 115 | } | 119 | } |
| 116 | 120 | ||
| 117 | function searchRow(row: SqlRow): SearchRow { | 121 | function searchRow(row: SqlRow): SearchRow { |
| 122 | + if (typeof row.lines !== "string") fail("invalid search row"); | ||
| 118 | const place = { | 123 | const place = { |
| 119 | placeId: row.placeId, | 124 | placeId: row.placeId, |
| 120 | stopLabel: row.stopLabel, | 125 | stopLabel: row.stopLabel, |
| 121 | ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }), | 126 | ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }), |
| 122 | mode: row.mode, | 127 | mode: row.mode, |
| 128 | + lines: placeLinesFromJson(row.lines), | ||
| 123 | }; | 129 | }; |
| 124 | if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId) | 130 | if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId) |
| 125 | || typeof row.searchText !== "string" || row.searchText === "" | 131 | || typeof row.searchText !== "string" || row.searchText === "" |
| @@ -209,9 +215,10 @@ export function publishStaticCatalog(options: PublishStaticCatalogOptions): Stat | |||
| 209 | } | 215 | } |
| 210 | // Hash the same pinned SQLite snapshot that will be exported, in bounded rows. | 216 | // Hash the same pinned SQLite snapshot that will be exported, in bounded rows. |
| 211 | const digest = createHash("sha256").update(EXPORT_FORMAT).update(JSON.stringify({ metadata, attribution })); | 217 | const digest = createHash("sha256").update(EXPORT_FORMAT).update(JSON.stringify({ metadata, attribution })); |
| 212 | - for (const query of [PLACE_QUERY, SERVICE_QUERY]) { | 218 | + for (const row of database.prepare(PLACE_QUERY).iterate() as Iterable<SqlRow>) { |
| 213 | - for (const row of database.prepare(query).iterate()) digest.update(JSON.stringify(row)).update("\n"); | 219 | + digest.update(JSON.stringify(searchRow(row))).update("\n"); |
| 214 | } | 220 | } |
| 221 | + for (const row of database.prepare(SERVICE_QUERY).iterate()) digest.update(JSON.stringify(row)).update("\n"); | ||
| 215 | const revision = digest.digest("hex"); | 222 | const revision = digest.digest("hex"); |
| 216 | const manifest: StaticCatalogManifest = { | 223 | const manifest: StaticCatalogManifest = { |
| 217 | schemaVersion: SCHEMA_VERSION, | 224 | schemaVersion: SCHEMA_VERSION, |
modified
packages/catalog/test/catalog.test.ts +75 -4 | @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; | ||
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { DatabaseSync } from "node:sqlite"; |
| 8 | -import { LIMITS, type TransportMode } from "../../contracts/src/index.ts"; | |
| 8 | +import { LIMITS, isPlaceSearchItem, type TransportMode } from "../../contracts/src/index.ts"; | |
| 9 | 9 | import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts"; |
| 10 | 10 | |
| 11 | 11 | interface PlaceSeed { |
| @@ -315,8 +315,8 @@ test("search is accent tolerant, punctuation safe, and bounded by Unicode code p | ||
| 315 | 315 | assert.throws(() => reader.searchPlaces("a"), RangeError); |
| 316 | 316 | assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError); |
| 317 | 317 | assert.throws(() => reader.searchPlaces("--"), RangeError); |
| 318 | - assert.doesNotThrow(() => reader.searchPlaces("😀a")); | |
| 319 | - assert.doesNotThrow(() => reader.searchPlaces("z".repeat(100))); | |
| 318 | + assert.deepEqual(reader.searchPlaces("😀a"), []); | |
| 319 | + assert.deepEqual(reader.searchPlaces("z".repeat(100)), []); | |
| 320 | 320 | }); |
| 321 | 321 | |
| 322 | 322 | test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => { |
| @@ -373,8 +373,76 @@ test("search preserves homonyms and applies deterministic ranking before the 20- | ||
| 373 | 373 | ); |
| 374 | 374 | }); |
| 375 | 375 | |
| 376 | +test("search deduplicates destinations by native line and keeps equal labels and homonymous modes", () => { | |
| 377 | + const places: PlaceSeed[] = (["BUS", "METRO"] as const).map((mode) => ({ | |
| 378 | + key: `university-${mode}`, | |
| 379 | + mode, | |
| 380 | + sourceCode: "zda-university", | |
| 381 | + stopLabel: "Saint-Denis Université", | |
| 382 | + localityLabel: "Saint-Denis", | |
| 383 | + })); | |
| 384 | + const busLines = [ | |
| 385 | + { lineRef: "native-10", lineLabel: "10", lineColor: "#333333" }, | |
| 386 | + { lineRef: "native-2-b", lineLabel: "2", lineColor: "#222222" }, | |
| 387 | + { lineRef: "native-2-c", lineLabel: "2", lineColor: "#111111" }, | |
| 388 | + { lineRef: "native-2-a", lineLabel: "2", lineColor: "#111111" }, | |
| 389 | + ]; | |
| 390 | + const services: ServiceSeed[] = busLines.flatMap((line, index) => [0, 1].map((direction) => ({ | |
| 391 | + ...line, | |
| 392 | + key: `university-bus-${index}-${direction}`, | |
| 393 | + placeKey: "university-BUS", | |
| 394 | + mode: "BUS" as const, | |
| 395 | + stopLabel: "Saint-Denis Université", | |
| 396 | + lineTextColor: "#ffffff", | |
| 397 | + destinationLabel: `Terminus ${direction}`, | |
| 398 | + monitoringRef: `university-bus-${direction}`, | |
| 399 | + directionId: String(direction), | |
| 400 | + destinationRef: `university-terminal-${direction}`, | |
| 401 | + }))); | |
| 402 | + services.push({ | |
| 403 | + key: "university-metro", | |
| 404 | + placeKey: "university-METRO", | |
| 405 | + mode: "METRO", | |
| 406 | + stopLabel: "Saint-Denis Université", | |
| 407 | + lineLabel: "13", | |
| 408 | + lineColor: "#82c8e6", | |
| 409 | + lineTextColor: "#000000", | |
| 410 | + destinationLabel: "Châtillon Montrouge", | |
| 411 | + monitoringRef: "university-metro", | |
| 412 | + lineRef: "native-metro-13", | |
| 413 | + directionId: "0", | |
| 414 | + destinationRef: "metro-terminal", | |
| 415 | + }); | |
| 416 | + const catalog = buildCatalog({ places, services }); | |
| 417 | + const matches = SqliteCatalogReader.open(catalog.path).searchPlaces("universite"); | |
| 418 | + assert.equal(matches.length, 2); | |
| 419 | + assert.equal(matches.every(isPlaceSearchItem), true); | |
| 420 | + assert.deepEqual(matches.find((place) => place.mode === "BUS")?.lines, [ | |
| 421 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | |
| 422 | + { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" }, | |
| 423 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | |
| 424 | + { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" }, | |
| 425 | + ]); | |
| 426 | + assert.deepEqual(matches.find((place) => place.mode === "METRO")?.lines, [ | |
| 427 | + { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" }, | |
| 428 | + ]); | |
| 429 | +}); | |
| 430 | + | |
| 431 | +test("search rejects conflicting display metadata for one native line", () => { | |
| 432 | + const catalog = buildCatalog({ | |
| 433 | + services: [{ | |
| 434 | + ...BASE_SERVICES[0]!, | |
| 435 | + key: "bus-other-destination", | |
| 436 | + destinationLabel: "Other terminus", | |
| 437 | + destinationRef: "terminal-bus-other", | |
| 438 | + lineColor: "#654321", | |
| 439 | + }], | |
| 440 | + }); | |
| 441 | + assert.throws(() => SqliteCatalogReader.open(catalog.path).searchPlaces("republique"), /inconsistent line metadata/u); | |
| 442 | +}); | |
| 443 | + | |
| 376 | 444 | test("service options return every valid direction without applying the place-search bound", () => { |
| 377 | - const extraServices: ServiceSeed[] = Array.from({ length: 25 }, (_, index) => ({ | |
| 445 | + const extraServices: ServiceSeed[] = Array.from({ length: 65 }, (_, index) => ({ | |
| 378 | 446 | key: `bus-option-${index}`, |
| 379 | 447 | placeKey: "bus", |
| 380 | 448 | mode: "BUS", |
| @@ -391,6 +459,9 @@ test("service options return every valid direction without applying the place-se | ||
| 391 | 459 | |
| 392 | 460 | const options = reader.listServices(catalog.placeIds.bus); |
| 393 | 461 | assert.equal(options?.length, extraServices.length + 1); |
| 462 | + const place = reader.searchPlaces("republique").find((entry) => entry.placeId === catalog.placeIds.bus); | |
| 463 | + assert.equal(isPlaceSearchItem(place), true); | |
| 464 | + assert.deepEqual(place?.lines.map((line) => line.lineLabel), ["96", ...extraServices.map((service) => service.lineLabel)]); | |
| 394 | 465 | }); |
| 395 | 466 | |
| 396 | 467 | test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => { |
| @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs"; | |||
| 5 | import { tmpdir } from "node:os"; | 5 | import { tmpdir } from "node:os"; |
| 6 | import { join } from "node:path"; | 6 | import { join } from "node:path"; |
| 7 | import { DatabaseSync } from "node:sqlite"; | 7 | import { DatabaseSync } from "node:sqlite"; |
| 8 | -import { LIMITS, type TransportMode } from "../../contracts/src/index.ts"; | 8 | +import { LIMITS, isPlaceSearchItem, type TransportMode } from "../../contracts/src/index.ts"; |
| 9 | import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts"; | 9 | import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts"; |
| 10 | 10 | ||
| 11 | interface PlaceSeed { | 11 | interface PlaceSeed { |
| @@ -315,8 +315,8 @@ test("search is accent tolerant, punctuation safe, and bounded by Unicode code p | |||
| 315 | assert.throws(() => reader.searchPlaces("a"), RangeError); | 315 | assert.throws(() => reader.searchPlaces("a"), RangeError); |
| 316 | assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError); | 316 | assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError); |
| 317 | assert.throws(() => reader.searchPlaces("--"), RangeError); | 317 | assert.throws(() => reader.searchPlaces("--"), RangeError); |
| 318 | - assert.doesNotThrow(() => reader.searchPlaces("😀a")); | 318 | + assert.deepEqual(reader.searchPlaces("😀a"), []); |
| 319 | - assert.doesNotThrow(() => reader.searchPlaces("z".repeat(100))); | 319 | + assert.deepEqual(reader.searchPlaces("z".repeat(100)), []); |
| 320 | }); | 320 | }); |
| 321 | 321 | ||
| 322 | test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => { | 322 | test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => { |
| @@ -373,8 +373,76 @@ test("search preserves homonyms and applies deterministic ranking before the 20- | |||
| 373 | ); | 373 | ); |
| 374 | }); | 374 | }); |
| 375 | 375 | ||
| 376 | +test("search deduplicates destinations by native line and keeps equal labels and homonymous modes", () => { | ||
| 377 | + const places: PlaceSeed[] = (["BUS", "METRO"] as const).map((mode) => ({ | ||
| 378 | + key: `university-${mode}`, | ||
| 379 | + mode, | ||
| 380 | + sourceCode: "zda-university", | ||
| 381 | + stopLabel: "Saint-Denis Université", | ||
| 382 | + localityLabel: "Saint-Denis", | ||
| 383 | + })); | ||
| 384 | + const busLines = [ | ||
| 385 | + { lineRef: "native-10", lineLabel: "10", lineColor: "#333333" }, | ||
| 386 | + { lineRef: "native-2-b", lineLabel: "2", lineColor: "#222222" }, | ||
| 387 | + { lineRef: "native-2-c", lineLabel: "2", lineColor: "#111111" }, | ||
| 388 | + { lineRef: "native-2-a", lineLabel: "2", lineColor: "#111111" }, | ||
| 389 | + ]; | ||
| 390 | + const services: ServiceSeed[] = busLines.flatMap((line, index) => [0, 1].map((direction) => ({ | ||
| 391 | + ...line, | ||
| 392 | + key: `university-bus-${index}-${direction}`, | ||
| 393 | + placeKey: "university-BUS", | ||
| 394 | + mode: "BUS" as const, | ||
| 395 | + stopLabel: "Saint-Denis Université", | ||
| 396 | + lineTextColor: "#ffffff", | ||
| 397 | + destinationLabel: `Terminus ${direction}`, | ||
| 398 | + monitoringRef: `university-bus-${direction}`, | ||
| 399 | + directionId: String(direction), | ||
| 400 | + destinationRef: `university-terminal-${direction}`, | ||
| 401 | + }))); | ||
| 402 | + services.push({ | ||
| 403 | + key: "university-metro", | ||
| 404 | + placeKey: "university-METRO", | ||
| 405 | + mode: "METRO", | ||
| 406 | + stopLabel: "Saint-Denis Université", | ||
| 407 | + lineLabel: "13", | ||
| 408 | + lineColor: "#82c8e6", | ||
| 409 | + lineTextColor: "#000000", | ||
| 410 | + destinationLabel: "Châtillon Montrouge", | ||
| 411 | + monitoringRef: "university-metro", | ||
| 412 | + lineRef: "native-metro-13", | ||
| 413 | + directionId: "0", | ||
| 414 | + destinationRef: "metro-terminal", | ||
| 415 | + }); | ||
| 416 | + const catalog = buildCatalog({ places, services }); | ||
| 417 | + const matches = SqliteCatalogReader.open(catalog.path).searchPlaces("universite"); | ||
| 418 | + assert.equal(matches.length, 2); | ||
| 419 | + assert.equal(matches.every(isPlaceSearchItem), true); | ||
| 420 | + assert.deepEqual(matches.find((place) => place.mode === "BUS")?.lines, [ | ||
| 421 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | ||
| 422 | + { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" }, | ||
| 423 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | ||
| 424 | + { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" }, | ||
| 425 | + ]); | ||
| 426 | + assert.deepEqual(matches.find((place) => place.mode === "METRO")?.lines, [ | ||
| 427 | + { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" }, | ||
| 428 | + ]); | ||
| 429 | +}); | ||
| 430 | + | ||
| 431 | +test("search rejects conflicting display metadata for one native line", () => { | ||
| 432 | + const catalog = buildCatalog({ | ||
| 433 | + services: [{ | ||
| 434 | + ...BASE_SERVICES[0]!, | ||
| 435 | + key: "bus-other-destination", | ||
| 436 | + destinationLabel: "Other terminus", | ||
| 437 | + destinationRef: "terminal-bus-other", | ||
| 438 | + lineColor: "#654321", | ||
| 439 | + }], | ||
| 440 | + }); | ||
| 441 | + assert.throws(() => SqliteCatalogReader.open(catalog.path).searchPlaces("republique"), /inconsistent line metadata/u); | ||
| 442 | +}); | ||
| 443 | + | ||
| 376 | test("service options return every valid direction without applying the place-search bound", () => { | 444 | test("service options return every valid direction without applying the place-search bound", () => { |
| 377 | - const extraServices: ServiceSeed[] = Array.from({ length: 25 }, (_, index) => ({ | 445 | + const extraServices: ServiceSeed[] = Array.from({ length: 65 }, (_, index) => ({ |
| 378 | key: `bus-option-${index}`, | 446 | key: `bus-option-${index}`, |
| 379 | placeKey: "bus", | 447 | placeKey: "bus", |
| 380 | mode: "BUS", | 448 | mode: "BUS", |
| @@ -391,6 +459,9 @@ test("service options return every valid direction without applying the place-se | |||
| 391 | 459 | ||
| 392 | const options = reader.listServices(catalog.placeIds.bus); | 460 | const options = reader.listServices(catalog.placeIds.bus); |
| 393 | assert.equal(options?.length, extraServices.length + 1); | 461 | assert.equal(options?.length, extraServices.length + 1); |
| 462 | + const place = reader.searchPlaces("republique").find((entry) => entry.placeId === catalog.placeIds.bus); | ||
| 463 | + assert.equal(isPlaceSearchItem(place), true); | ||
| 464 | + assert.deepEqual(place?.lines.map((line) => line.lineLabel), ["96", ...extraServices.map((service) => service.lineLabel)]); | ||
| 394 | }); | 465 | }); |
| 395 | 466 | ||
| 396 | test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => { | 467 | test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => { |
modified
packages/catalog/test/static-catalog.test.js +109 -4 | @@ -4,8 +4,9 @@ import { existsSync, mkdtempSync, opendirSync, readFileSync, rmSync } from "node | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { DatabaseSync } from "node:sqlite"; |
| 7 | -import { LIMITS, TRANSPORT_MODE } from "../../contracts/src/index.ts"; | |
| 7 | +import { LIMITS, TRANSPORT_MODE, isPlaceSearchItem } from "../../contracts/src/index.ts"; | |
| 8 | 8 | import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts"; |
| 9 | +import { SqliteCatalogReader } from "../src/catalog.ts"; | |
| 9 | 10 | import { publishStaticCatalog } from "../src/static-catalog.ts"; |
| 10 | 11 | import { createCatalogClient } from "../../config-page/src/catalog-client.js"; |
| 11 | 12 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; |
| @@ -58,12 +59,22 @@ test("validated catalog publication preserves all modes, stable service identity | ||
| 58 | 59 | const options = await catalogFixture(t); |
| 59 | 60 | const publication = publishStaticCatalog(options); |
| 60 | 61 | const client = pageClient(options.outputDirectory); |
| 62 | + const reader = SqliteCatalogReader.open(options.catalogPath); | |
| 61 | 63 | const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"]; |
| 62 | 64 | const modes = new Set(); |
| 63 | 65 | for (const query of selections) { |
| 64 | 66 | for (const place of await client.searchPlaces(query)) { |
| 65 | 67 | modes.add(place.mode); |
| 66 | - for (const service of await client.listServices(place.placeId)) { | |
| 68 | + assert.equal(isPlaceSearchItem(place), true); | |
| 69 | + assert.deepEqual(place, reader.searchPlaces(query).find((entry) => entry.placeId === place.placeId)); | |
| 70 | + const services = await client.listServices(place.placeId); | |
| 71 | + const nativeLines = new Map(services.map((service) => [service.routing.lineRef, { | |
| 72 | + lineLabel: service.lineLabel, lineColor: service.lineColor, lineTextColor: service.lineTextColor, | |
| 73 | + }])); | |
| 74 | + assert.equal(place.lines.length, nativeLines.size); | |
| 75 | + for (const line of nativeLines.values()) assert.ok(place.lines.some((entry) => | |
| 76 | + entry.lineLabel === line.lineLabel && entry.lineColor === line.lineColor && entry.lineTextColor === line.lineTextColor)); | |
| 77 | + for (const service of services) { | |
| 67 | 78 | assert.equal(service.lineMode, place.mode); |
| 68 | 79 | assert.deepEqual(await client.lookupService(service.serviceId), service); |
| 69 | 80 | } |
| @@ -88,6 +99,83 @@ test("validated catalog publication preserves all modes, stable service identity | ||
| 88 | 99 | assert.deepEqual(readFileSync(join(options.outputDirectory, "manifest.json")), before); |
| 89 | 100 | }); |
| 90 | 101 | |
| 102 | +test("search publication keeps every native line in natural order and refreshes display metadata by revision", async (t) => { | |
| 103 | + const options = await catalogFixture(t); | |
| 104 | + const database = new DatabaseSync(options.catalogPath); | |
| 105 | + const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | |
| 106 | + const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | |
| 107 | + const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | |
| 108 | + const busLines = [ | |
| 109 | + { lineId: "C-UNI10", lineLabel: "10", lineColor: "#333333" }, | |
| 110 | + { lineId: "C-UNI2-B", lineLabel: "2", lineColor: "#222222" }, | |
| 111 | + { lineId: "C-UNI2-C", lineLabel: "2", lineColor: "#111111" }, | |
| 112 | + { lineId: "C-UNI2-A", lineLabel: "2", lineColor: "#111111" }, | |
| 113 | + ]; | |
| 114 | + try { | |
| 115 | + database.exec("BEGIN"); | |
| 116 | + for (const [mode, lines] of [ | |
| 117 | + ["BUS", busLines], | |
| 118 | + ["METRO", [{ lineId: "C-UNI13", lineLabel: "13", lineColor: "#82c8e6" }]], | |
| 119 | + ]) { | |
| 120 | + const place = createPlaceIdentity(mode, "STATIC-UNIVERSITY"); | |
| 121 | + insertPlace.run(place.placeId, "Saint-Denis Université", "Saint-Denis", mode, place.canonicalTuple); | |
| 122 | + insertSearch.run(normalizeCatalogSearchText(`Saint-Denis Université ${mode}`), place.placeId); | |
| 123 | + for (const line of lines) { | |
| 124 | + for (const direction of ["0", "1"]) { | |
| 125 | + const monitoringRef = `fixture:university:${mode}:${direction}`; | |
| 126 | + const destinationRef = `fixture:university:terminal:${direction}`; | |
| 127 | + const service = createServiceIdentity({ mode, lineId: line.lineId, monitoringRef, directionId: direction, destinationRef }); | |
| 128 | + insertService.run( | |
| 129 | + service.serviceId, place.placeId, "Saint-Denis Université", line.lineLabel, `Terminus ${direction}`, | |
| 130 | + line.lineColor, "#ffffff", monitoringRef, `IDFM:${line.lineId}`, direction, destinationRef, service.canonicalTuple, | |
| 131 | + ); | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | + database.exec("COMMIT"); | |
| 136 | + } finally { | |
| 137 | + database.close(); | |
| 138 | + } | |
| 139 | + | |
| 140 | + const first = publishStaticCatalog(options); | |
| 141 | + const requests = []; | |
| 142 | + const oldClient = pageClient(options.outputDirectory, requests); | |
| 143 | + const places = await oldClient.searchPlaces("universite"); | |
| 144 | + assert.equal(places.length, 2); | |
| 145 | + assert.equal(places.every(isPlaceSearchItem), true); | |
| 146 | + assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false); | |
| 147 | + const expectedBus = [ | |
| 148 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | |
| 149 | + { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" }, | |
| 150 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | |
| 151 | + { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" }, | |
| 152 | + ]; | |
| 153 | + assert.deepEqual(places.find((place) => place.mode === "BUS").lines, expectedBus); | |
| 154 | + assert.deepEqual(places.find((place) => place.mode === "METRO").lines, [ | |
| 155 | + { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#ffffff" }, | |
| 156 | + ]); | |
| 157 | + const live = SqliteCatalogReader.open(options.catalogPath).searchPlaces("universite"); | |
| 158 | + for (const place of places) assert.deepEqual(place, live.find((entry) => entry.placeId === place.placeId)); | |
| 159 | + | |
| 160 | + const update = new DatabaseSync(options.catalogPath); | |
| 161 | + try { | |
| 162 | + update.prepare("UPDATE services SET line_label = ?, line_color = ?, line_text_color = ? WHERE line_ref = ?") | |
| 163 | + .run("3", "#abcdef", "#000000", "IDFM:C-UNI2-B"); | |
| 164 | + } finally { | |
| 165 | + update.close(); | |
| 166 | + } | |
| 167 | + const second = publishStaticCatalog(options); | |
| 168 | + assert.equal(second.manifest.sourceRevision, first.manifest.sourceRevision); | |
| 169 | + assert.notEqual(second.manifest.revision, first.manifest.revision); | |
| 170 | + const refreshed = await pageClient(options.outputDirectory).searchPlaces("universite"); | |
| 171 | + assert.deepEqual(refreshed.find((place) => place.mode === "BUS").lines, [ | |
| 172 | + expectedBus[0], expectedBus[2], | |
| 173 | + { lineLabel: "3", lineColor: "#abcdef", lineTextColor: "#000000" }, | |
| 174 | + expectedBus[3], | |
| 175 | + ]); | |
| 176 | + assert.deepEqual((await oldClient.searchPlaces("universite")).find((place) => place.mode === "BUS").lines, expectedBus); | |
| 177 | +}); | |
| 178 | + | |
| 91 | 179 | test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => { |
| 92 | 180 | const first = await catalogFixture(t); |
| 93 | 181 | const oldPublication = publishStaticCatalog(first); |
| @@ -129,9 +217,10 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | ||
| 129 | 217 | insertSearch.run("busy fixture", busy.placeId); |
| 130 | 218 | for (let index = 0; index < 12; index += 1) { |
| 131 | 219 | const destinationRef = `fixture:busy:${index}`; |
| 132 | - const service = createServiceIdentity({ mode: "BUS", lineId: "C-BUSY", monitoringRef: longMonitoring, directionId: "0", destinationRef }); | |
| 220 | + const lineId = `C-BUSY-${index}`; | |
| 221 | + const service = createServiceIdentity({ mode: "BUS", lineId, monitoringRef: longMonitoring, directionId: "0", destinationRef }); | |
| 133 | 222 | longServiceId ??= service.serviceId; |
| 134 | - insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, "IDFM:C-BUSY", "0", destinationRef, service.canonicalTuple); | |
| 223 | + insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, `IDFM:${lineId}`, "0", destinationRef, service.canonicalTuple); | |
| 135 | 224 | } |
| 136 | 225 | database.exec("COMMIT"); |
| 137 | 226 | } finally { |
| @@ -147,6 +236,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | ||
| 147 | 236 | const client = pageClient(options.outputDirectory, requests); |
| 148 | 237 | const matches = await client.searchPlaces("café"); |
| 149 | 238 | assert.deepEqual(matches.map((place) => place.stopLabel.slice(0, 9)), Array.from({ length: 20 }, (_, index) => `Café ${String(index).padStart(4, "0")}`)); |
| 239 | + assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false); | |
| 240 | + for (const place of matches) assert.deepEqual(place.lines, [ | |
| 241 | + { lineLabel: "99", lineColor: "#123456", lineTextColor: "#ffffff" }, | |
| 242 | + ]); | |
| 243 | + const busyMatches = await client.searchPlaces("busy"); | |
| 244 | + assert.deepEqual(busyMatches[0].lines, Array.from({ length: 12 }, (_, index) => ({ | |
| 245 | + lineLabel: String(index).padStart(2, "0"), lineColor: "#123456", lineTextColor: "#ffffff", | |
| 246 | + }))); | |
| 150 | 247 | const services = await client.listServices(busy.placeId); |
| 151 | 248 | assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0"))); |
| 152 | 249 | assert.equal((await client.lookupService(longServiceId)).routing.monitoringRef, longMonitoring); |
| @@ -154,6 +251,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | ||
| 154 | 251 | assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true); |
| 155 | 252 | assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId)); |
| 156 | 253 | assert.equal(requests.some((url) => url.includes("/search/63/1.json")), true); |
| 254 | + const exportedPlaces = []; | |
| 255 | + for (let page = 0; page !== null;) { | |
| 256 | + const body = JSON.parse(readFileSync(join(options.outputDirectory, revision, "search", "63_61", `${page}.json`), "utf8")); | |
| 257 | + exportedPlaces.push(...body.places.map((place) => place.placeId)); | |
| 258 | + page = body.nextPage; | |
| 259 | + } | |
| 260 | + assert.deepEqual(exportedPlaces.sort(), Array.from({ length: 800 }, (_, index) => | |
| 261 | + createPlaceIdentity("BUS", `STATIC-${index}`).placeId).sort()); | |
| 157 | 262 | let fileCount = 0; |
| 158 | 263 | let totalBytes = 0; |
| 159 | 264 | let maximum = 0; |
| @@ -4,8 +4,9 @@ import { existsSync, mkdtempSync, opendirSync, readFileSync, rmSync } from "node | |||
| 4 | import { tmpdir } from "node:os"; | 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| 6 | import { DatabaseSync } from "node:sqlite"; | 6 | import { DatabaseSync } from "node:sqlite"; |
| 7 | -import { LIMITS, TRANSPORT_MODE } from "../../contracts/src/index.ts"; | 7 | +import { LIMITS, TRANSPORT_MODE, isPlaceSearchItem } from "../../contracts/src/index.ts"; |
| 8 | import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts"; | 8 | import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts"; |
| 9 | +import { SqliteCatalogReader } from "../src/catalog.ts"; | ||
| 9 | import { publishStaticCatalog } from "../src/static-catalog.ts"; | 10 | import { publishStaticCatalog } from "../src/static-catalog.ts"; |
| 10 | import { createCatalogClient } from "../../config-page/src/catalog-client.js"; | 11 | import { createCatalogClient } from "../../config-page/src/catalog-client.js"; |
| 11 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; | 12 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; |
| @@ -58,12 +59,22 @@ test("validated catalog publication preserves all modes, stable service identity | |||
| 58 | const options = await catalogFixture(t); | 59 | const options = await catalogFixture(t); |
| 59 | const publication = publishStaticCatalog(options); | 60 | const publication = publishStaticCatalog(options); |
| 60 | const client = pageClient(options.outputDirectory); | 61 | const client = pageClient(options.outputDirectory); |
| 62 | + const reader = SqliteCatalogReader.open(options.catalogPath); | ||
| 61 | const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"]; | 63 | const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"]; |
| 62 | const modes = new Set(); | 64 | const modes = new Set(); |
| 63 | for (const query of selections) { | 65 | for (const query of selections) { |
| 64 | for (const place of await client.searchPlaces(query)) { | 66 | for (const place of await client.searchPlaces(query)) { |
| 65 | modes.add(place.mode); | 67 | modes.add(place.mode); |
| 66 | - for (const service of await client.listServices(place.placeId)) { | 68 | + assert.equal(isPlaceSearchItem(place), true); |
| 69 | + assert.deepEqual(place, reader.searchPlaces(query).find((entry) => entry.placeId === place.placeId)); | ||
| 70 | + const services = await client.listServices(place.placeId); | ||
| 71 | + const nativeLines = new Map(services.map((service) => [service.routing.lineRef, { | ||
| 72 | + lineLabel: service.lineLabel, lineColor: service.lineColor, lineTextColor: service.lineTextColor, | ||
| 73 | + }])); | ||
| 74 | + assert.equal(place.lines.length, nativeLines.size); | ||
| 75 | + for (const line of nativeLines.values()) assert.ok(place.lines.some((entry) => | ||
| 76 | + entry.lineLabel === line.lineLabel && entry.lineColor === line.lineColor && entry.lineTextColor === line.lineTextColor)); | ||
| 77 | + for (const service of services) { | ||
| 67 | assert.equal(service.lineMode, place.mode); | 78 | assert.equal(service.lineMode, place.mode); |
| 68 | assert.deepEqual(await client.lookupService(service.serviceId), service); | 79 | assert.deepEqual(await client.lookupService(service.serviceId), service); |
| 69 | } | 80 | } |
| @@ -88,6 +99,83 @@ test("validated catalog publication preserves all modes, stable service identity | |||
| 88 | assert.deepEqual(readFileSync(join(options.outputDirectory, "manifest.json")), before); | 99 | assert.deepEqual(readFileSync(join(options.outputDirectory, "manifest.json")), before); |
| 89 | }); | 100 | }); |
| 90 | 101 | ||
| 102 | +test("search publication keeps every native line in natural order and refreshes display metadata by revision", async (t) => { | ||
| 103 | + const options = await catalogFixture(t); | ||
| 104 | + const database = new DatabaseSync(options.catalogPath); | ||
| 105 | + const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)"); | ||
| 106 | + const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)"); | ||
| 107 | + const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); | ||
| 108 | + const busLines = [ | ||
| 109 | + { lineId: "C-UNI10", lineLabel: "10", lineColor: "#333333" }, | ||
| 110 | + { lineId: "C-UNI2-B", lineLabel: "2", lineColor: "#222222" }, | ||
| 111 | + { lineId: "C-UNI2-C", lineLabel: "2", lineColor: "#111111" }, | ||
| 112 | + { lineId: "C-UNI2-A", lineLabel: "2", lineColor: "#111111" }, | ||
| 113 | + ]; | ||
| 114 | + try { | ||
| 115 | + database.exec("BEGIN"); | ||
| 116 | + for (const [mode, lines] of [ | ||
| 117 | + ["BUS", busLines], | ||
| 118 | + ["METRO", [{ lineId: "C-UNI13", lineLabel: "13", lineColor: "#82c8e6" }]], | ||
| 119 | + ]) { | ||
| 120 | + const place = createPlaceIdentity(mode, "STATIC-UNIVERSITY"); | ||
| 121 | + insertPlace.run(place.placeId, "Saint-Denis Université", "Saint-Denis", mode, place.canonicalTuple); | ||
| 122 | + insertSearch.run(normalizeCatalogSearchText(`Saint-Denis Université ${mode}`), place.placeId); | ||
| 123 | + for (const line of lines) { | ||
| 124 | + for (const direction of ["0", "1"]) { | ||
| 125 | + const monitoringRef = `fixture:university:${mode}:${direction}`; | ||
| 126 | + const destinationRef = `fixture:university:terminal:${direction}`; | ||
| 127 | + const service = createServiceIdentity({ mode, lineId: line.lineId, monitoringRef, directionId: direction, destinationRef }); | ||
| 128 | + insertService.run( | ||
| 129 | + service.serviceId, place.placeId, "Saint-Denis Université", line.lineLabel, `Terminus ${direction}`, | ||
| 130 | + line.lineColor, "#ffffff", monitoringRef, `IDFM:${line.lineId}`, direction, destinationRef, service.canonicalTuple, | ||
| 131 | + ); | ||
| 132 | + } | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + database.exec("COMMIT"); | ||
| 136 | + } finally { | ||
| 137 | + database.close(); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + const first = publishStaticCatalog(options); | ||
| 141 | + const requests = []; | ||
| 142 | + const oldClient = pageClient(options.outputDirectory, requests); | ||
| 143 | + const places = await oldClient.searchPlaces("universite"); | ||
| 144 | + assert.equal(places.length, 2); | ||
| 145 | + assert.equal(places.every(isPlaceSearchItem), true); | ||
| 146 | + assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false); | ||
| 147 | + const expectedBus = [ | ||
| 148 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | ||
| 149 | + { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" }, | ||
| 150 | + { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" }, | ||
| 151 | + { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" }, | ||
| 152 | + ]; | ||
| 153 | + assert.deepEqual(places.find((place) => place.mode === "BUS").lines, expectedBus); | ||
| 154 | + assert.deepEqual(places.find((place) => place.mode === "METRO").lines, [ | ||
| 155 | + { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#ffffff" }, | ||
| 156 | + ]); | ||
| 157 | + const live = SqliteCatalogReader.open(options.catalogPath).searchPlaces("universite"); | ||
| 158 | + for (const place of places) assert.deepEqual(place, live.find((entry) => entry.placeId === place.placeId)); | ||
| 159 | + | ||
| 160 | + const update = new DatabaseSync(options.catalogPath); | ||
| 161 | + try { | ||
| 162 | + update.prepare("UPDATE services SET line_label = ?, line_color = ?, line_text_color = ? WHERE line_ref = ?") | ||
| 163 | + .run("3", "#abcdef", "#000000", "IDFM:C-UNI2-B"); | ||
| 164 | + } finally { | ||
| 165 | + update.close(); | ||
| 166 | + } | ||
| 167 | + const second = publishStaticCatalog(options); | ||
| 168 | + assert.equal(second.manifest.sourceRevision, first.manifest.sourceRevision); | ||
| 169 | + assert.notEqual(second.manifest.revision, first.manifest.revision); | ||
| 170 | + const refreshed = await pageClient(options.outputDirectory).searchPlaces("universite"); | ||
| 171 | + assert.deepEqual(refreshed.find((place) => place.mode === "BUS").lines, [ | ||
| 172 | + expectedBus[0], expectedBus[2], | ||
| 173 | + { lineLabel: "3", lineColor: "#abcdef", lineTextColor: "#000000" }, | ||
| 174 | + expectedBus[3], | ||
| 175 | + ]); | ||
| 176 | + assert.deepEqual((await oldClient.searchPlaces("universite")).find((place) => place.mode === "BUS").lines, expectedBus); | ||
| 177 | +}); | ||
| 178 | + | ||
| 91 | test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => { | 179 | test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => { |
| 92 | const first = await catalogFixture(t); | 180 | const first = await catalogFixture(t); |
| 93 | const oldPublication = publishStaticCatalog(first); | 181 | const oldPublication = publishStaticCatalog(first); |
| @@ -129,9 +217,10 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | |||
| 129 | insertSearch.run("busy fixture", busy.placeId); | 217 | insertSearch.run("busy fixture", busy.placeId); |
| 130 | for (let index = 0; index < 12; index += 1) { | 218 | for (let index = 0; index < 12; index += 1) { |
| 131 | const destinationRef = `fixture:busy:${index}`; | 219 | const destinationRef = `fixture:busy:${index}`; |
| 132 | - const service = createServiceIdentity({ mode: "BUS", lineId: "C-BUSY", monitoringRef: longMonitoring, directionId: "0", destinationRef }); | 220 | + const lineId = `C-BUSY-${index}`; |
| 221 | + const service = createServiceIdentity({ mode: "BUS", lineId, monitoringRef: longMonitoring, directionId: "0", destinationRef }); | ||
| 133 | longServiceId ??= service.serviceId; | 222 | longServiceId ??= service.serviceId; |
| 134 | - insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, "IDFM:C-BUSY", "0", destinationRef, service.canonicalTuple); | 223 | + insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, `IDFM:${lineId}`, "0", destinationRef, service.canonicalTuple); |
| 135 | } | 224 | } |
| 136 | database.exec("COMMIT"); | 225 | database.exec("COMMIT"); |
| 137 | } finally { | 226 | } finally { |
| @@ -147,6 +236,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | |||
| 147 | const client = pageClient(options.outputDirectory, requests); | 236 | const client = pageClient(options.outputDirectory, requests); |
| 148 | const matches = await client.searchPlaces("café"); | 237 | const matches = await client.searchPlaces("café"); |
| 149 | assert.deepEqual(matches.map((place) => place.stopLabel.slice(0, 9)), Array.from({ length: 20 }, (_, index) => `Café ${String(index).padStart(4, "0")}`)); | 238 | assert.deepEqual(matches.map((place) => place.stopLabel.slice(0, 9)), Array.from({ length: 20 }, (_, index) => `Café ${String(index).padStart(4, "0")}`)); |
| 239 | + assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false); | ||
| 240 | + for (const place of matches) assert.deepEqual(place.lines, [ | ||
| 241 | + { lineLabel: "99", lineColor: "#123456", lineTextColor: "#ffffff" }, | ||
| 242 | + ]); | ||
| 243 | + const busyMatches = await client.searchPlaces("busy"); | ||
| 244 | + assert.deepEqual(busyMatches[0].lines, Array.from({ length: 12 }, (_, index) => ({ | ||
| 245 | + lineLabel: String(index).padStart(2, "0"), lineColor: "#123456", lineTextColor: "#ffffff", | ||
| 246 | + }))); | ||
| 150 | const services = await client.listServices(busy.placeId); | 247 | const services = await client.listServices(busy.placeId); |
| 151 | assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0"))); | 248 | assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0"))); |
| 152 | assert.equal((await client.lookupService(longServiceId)).routing.monitoringRef, longMonitoring); | 249 | assert.equal((await client.lookupService(longServiceId)).routing.monitoringRef, longMonitoring); |
| @@ -154,6 +251,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | |||
| 154 | assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true); | 251 | assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true); |
| 155 | assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId)); | 252 | assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId)); |
| 156 | assert.equal(requests.some((url) => url.includes("/search/63/1.json")), true); | 253 | assert.equal(requests.some((url) => url.includes("/search/63/1.json")), true); |
| 254 | + const exportedPlaces = []; | ||
| 255 | + for (let page = 0; page !== null;) { | ||
| 256 | + const body = JSON.parse(readFileSync(join(options.outputDirectory, revision, "search", "63_61", `${page}.json`), "utf8")); | ||
| 257 | + exportedPlaces.push(...body.places.map((place) => place.placeId)); | ||
| 258 | + page = body.nextPage; | ||
| 259 | + } | ||
| 260 | + assert.deepEqual(exportedPlaces.sort(), Array.from({ length: 800 }, (_, index) => | ||
| 261 | + createPlaceIdentity("BUS", `STATIC-${index}`).placeId).sort()); | ||
| 157 | let fileCount = 0; | 262 | let fileCount = 0; |
| 158 | let totalBytes = 0; | 263 | let totalBytes = 0; |
| 159 | let maximum = 0; | 264 | let maximum = 0; |
added
packages/config-page/assets/lapin-fute-1024.jpg +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.jpg differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.jpg differ | Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.jpg differ | ||
added
packages/config-page/assets/lapin-fute-1024.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.png differ | Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.png differ | ||
added
packages/config-page/assets/lapin-fute.svg +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="lapin-fute-title lapin-fute-desc"> | |
| 2 | + <title id="lapin-fute-title">Lapin Futé</title> | |
| 3 | + <desc id="lapin-fute-desc">Un billet rouge arrondi à deux encoches, percé d'un trajet en transparence qui dessine les oreilles et les joues du lapin et se termine par deux arrêts ronds.</desc> | |
| 4 | + <defs> | |
| 5 | + <mask id="lapin-fute-cutout" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"> | |
| 6 | + <rect width="64" height="64" fill="#ffffff"/> | |
| 7 | + <path fill="none" stroke="#000000" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" d="M25 50C19.6 48.8 16.5 45.5 16.5 41.3C16.5 37.4 19 34.3 22 32L18.8 18.5C18.1 15 19.5 13 22 13C25 13 26.6 16.9 27.1 21.3L28.5 30.5Q32 29.5 35.5 30.5L36.9 21.3C37.4 16.9 39 13 42 13C44.5 13 45.9 15 45.2 18.5L42 32C45 34.3 47.5 37.4 47.5 41.3C47.5 45.5 44.4 48.8 39 50"/> | |
| 8 | + <g fill="#000000"> | |
| 9 | + <circle cx="25" cy="50" r="3.5"/> | |
| 10 | + <circle cx="39" cy="50" r="3.5"/> | |
| 11 | + </g> | |
| 12 | + </mask> | |
| 13 | + </defs> | |
| 14 | + <path fill="#fa4a36" mask="url(#lapin-fute-cutout)" d="M15 4H49A11 11 0 0 1 60 15V26A6 6 0 0 0 60 38V49A11 11 0 0 1 49 60H15A11 11 0 0 1 4 49V38A6 6 0 0 0 4 26V15A11 11 0 0 1 15 4Z"/> | |
| 15 | +</svg> | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="lapin-fute-title lapin-fute-desc"> | ||
| 2 | + <title id="lapin-fute-title">Lapin Futé</title> | ||
| 3 | + <desc id="lapin-fute-desc">Un billet rouge arrondi à deux encoches, percé d'un trajet en transparence qui dessine les oreilles et les joues du lapin et se termine par deux arrêts ronds.</desc> | ||
| 4 | + <defs> | ||
| 5 | + <mask id="lapin-fute-cutout" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"> | ||
| 6 | + <rect width="64" height="64" fill="#ffffff"/> | ||
| 7 | + <path fill="none" stroke="#000000" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" d="M25 50C19.6 48.8 16.5 45.5 16.5 41.3C16.5 37.4 19 34.3 22 32L18.8 18.5C18.1 15 19.5 13 22 13C25 13 26.6 16.9 27.1 21.3L28.5 30.5Q32 29.5 35.5 30.5L36.9 21.3C37.4 16.9 39 13 42 13C44.5 13 45.9 15 45.2 18.5L42 32C45 34.3 47.5 37.4 47.5 41.3C47.5 45.5 44.4 48.8 39 50"/> | ||
| 8 | + <g fill="#000000"> | ||
| 9 | + <circle cx="25" cy="50" r="3.5"/> | ||
| 10 | + <circle cx="39" cy="50" r="3.5"/> | ||
| 11 | + </g> | ||
| 12 | + </mask> | ||
| 13 | + </defs> | ||
| 14 | + <path fill="#fa4a36" mask="url(#lapin-fute-cutout)" d="M15 4H49A11 11 0 0 1 60 15V26A6 6 0 0 0 60 38V49A11 11 0 0 1 49 60H15A11 11 0 0 1 4 49V38A6 6 0 0 0 4 26V15A11 11 0 0 1 15 4Z"/> | ||
| 15 | +</svg> | ||
modified
packages/config-page/index.html +116 -63 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/src/config-core.js +46 -5 | @@ -71,12 +71,16 @@ export function selectLocale(language) { | ||
| 71 | 71 | export const COPY = { |
| 72 | 72 | en: { |
| 73 | 73 | pageTitle: "Lapin Futé settings", |
| 74 | + pageHeading: "Settings", | |
| 74 | 75 | intro: "Choose the departures you want at hand on your Pebble.", |
| 75 | 76 | keyTitle: "PRIM access", |
| 76 | - keyStatusConfigured: "A key is configured on this phone.", | |
| 77 | + keyStatusConfigured: "A key is saved on this phone. It is not verified against PRIM.", | |
| 77 | 78 | keyStatusMissing: "No key is configured.", |
| 78 | 79 | keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.", |
| 79 | 80 | keyLink: "Create a PRIM token", |
| 81 | + keyDocsLink: "Instructions", | |
| 82 | + keyRequirement: "A personal PRIM token is required to retrieve live departures. You can prepare your favorites without one.", | |
| 83 | + keyRequiredHint: "PRIM token required for live departures", | |
| 80 | 84 | keyLabel: "Replace with a new key", |
| 81 | 85 | keyPlaceholder: "Paste a new key", |
| 82 | 86 | keyShow: "Show", |
| @@ -88,8 +92,13 @@ export const COPY = { | ||
| 88 | 92 | keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.", |
| 89 | 93 | keyErrorNewline: "The key must not contain control characters.", |
| 90 | 94 | favoritesTitle: "Favorite departures", |
| 95 | + favoriteUsed: "used", | |
| 96 | + favoritesUsed: "used", | |
| 97 | + favoriteAvailable: "available", | |
| 98 | + favoritesAvailable: "available", | |
| 91 | 99 | favoritesEmpty: "No favorites yet. Search for a stop to add one.", |
| 92 | 100 | favoriteAddTitle: "Add a favorite", |
| 101 | + favoriteAddCaption: "Stop, line and direction", | |
| 93 | 102 | searchLabel: "Stop or station", |
| 94 | 103 | searchPlaceholder: "Search by name", |
| 95 | 104 | searchHint: "Enter at least 2 characters.", |
| @@ -118,15 +127,20 @@ export const COPY = { | ||
| 118 | 127 | aboutBack: "Back to settings", |
| 119 | 128 | aboutTitle: "About Lapin Futé", |
| 120 | 129 | syncTitle: "Watch synchronization", |
| 130 | + syncCaption: "Transfer options", | |
| 121 | 131 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", |
| 122 | 132 | forceSyncLabel: "Force a full synchronization at the next save", |
| 123 | 133 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", |
| 124 | 134 | }, |
| 125 | 135 | fr: { |
| 126 | 136 | pageTitle: "Réglages Lapin Futé", |
| 137 | + pageHeading: "Réglages", | |
| 127 | 138 | intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.", |
| 128 | 139 | keyTitle: "Accès PRIM", |
| 129 | - keyStatusConfigured: "Une clé est configurée sur ce téléphone.", | |
| 140 | + keyStatusConfigured: "Une clé est enregistrée sur ce téléphone. Elle n’est pas vérifiée auprès de PRIM.", | |
| 141 | + keyDocsLink: "Instructions", | |
| 142 | + keyRequirement: "Un jeton PRIM personnel est obligatoire pour récupérer les prochains départs en temps réel. Vous pouvez préparer vos favoris sans jeton.", | |
| 143 | + keyRequiredHint: "Jeton PRIM requis pour les départs en temps réel", | |
| 130 | 144 | keyStatusMissing: "Aucune clé n’est configurée.", |
| 131 | 145 | keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.", |
| 132 | 146 | keyLink: "Créer un jeton PRIM", |
| @@ -141,8 +155,13 @@ export const COPY = { | ||
| 141 | 155 | keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.", |
| 142 | 156 | keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.", |
| 143 | 157 | favoritesTitle: "Départs favoris", |
| 158 | + favoriteUsed: "utilisé", | |
| 159 | + favoritesUsed: "utilisés", | |
| 160 | + favoriteAvailable: "disponible", | |
| 161 | + favoritesAvailable: "disponibles", | |
| 144 | 162 | favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", |
| 145 | 163 | favoriteAddTitle: "Ajouter un favori", |
| 164 | + favoriteAddCaption: "Arrêt, ligne et direction", | |
| 146 | 165 | searchLabel: "Arrêt ou gare", |
| 147 | 166 | searchPlaceholder: "Rechercher par nom", |
| 148 | 167 | searchHint: "Saisissez au moins 2 caractères.", |
| @@ -171,6 +190,7 @@ export const COPY = { | ||
| 171 | 190 | aboutBack: "Retour aux réglages", |
| 172 | 191 | aboutTitle: "À propos de Lapin Futé", |
| 173 | 192 | syncTitle: "Synchronisation de la montre", |
| 193 | + syncCaption: "Options de transfert", | |
| 174 | 194 | syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.", |
| 175 | 195 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", |
| 176 | 196 | saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.", |
| @@ -238,7 +258,8 @@ export function copyPhoneFavorite(favorite) { | ||
| 238 | 258 | return copy; |
| 239 | 259 | } |
| 240 | 260 | |
| 241 | -const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"]; | |
| 261 | +const PLACE_LINE_FIELDS = ["lineLabel", "lineColor", "lineTextColor"]; | |
| 262 | +const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode", "lines"]; | |
| 242 | 263 | const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"]; |
| 243 | 264 | const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]; |
| 244 | 265 | |
| @@ -248,12 +269,32 @@ function exactFields(value, allowed, required) { | ||
| 248 | 269 | && required.every((field) => Object.hasOwn(value, field)); |
| 249 | 270 | } |
| 250 | 271 | |
| 272 | +export function isPlaceLine(value) { | |
| 273 | + return exactFields(value, PLACE_LINE_FIELDS, PLACE_LINE_FIELDS) | |
| 274 | + && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | |
| 275 | + && isLineColor(value.lineColor) | |
| 276 | + && isLineColor(value.lineTextColor); | |
| 277 | +} | |
| 278 | + | |
| 279 | +// Producer-precomputed display lines for one search row: an entry per native | |
| 280 | +// line with no per-line mode or ref. The list is required and nonempty; the | |
| 281 | +// response byte bound (not a speculative count cap) limits its length. Holes | |
| 282 | +// are visited by for...of, which Array.prototype.every would silently skip. | |
| 283 | +function isPlaceLineList(lines) { | |
| 284 | + if (!Array.isArray(lines) || lines.length === 0) return false; | |
| 285 | + for (const line of lines) { | |
| 286 | + if (!isPlaceLine(line)) return false; | |
| 287 | + } | |
| 288 | + return true; | |
| 289 | +} | |
| 290 | + | |
| 251 | 291 | export function isPlaceSearchItem(value) { |
| 252 | - return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"]) | |
| 292 | + return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode", "lines"]) | |
| 253 | 293 | && boundedString(value.placeId, LIMITS.idUtf8Bytes) |
| 254 | 294 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 255 | 295 | && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes)) |
| 256 | - && TRANSPORT_MODES.includes(value.mode); | |
| 296 | + && TRANSPORT_MODES.includes(value.mode) | |
| 297 | + && isPlaceLineList(value.lines); | |
| 257 | 298 | } |
| 258 | 299 | |
| 259 | 300 | export function isPlaceSearchResult(value) { |
| @@ -71,12 +71,16 @@ export function selectLocale(language) { | |||
| 71 | export const COPY = { | 71 | export const COPY = { |
| 72 | en: { | 72 | en: { |
| 73 | pageTitle: "Lapin Futé settings", | 73 | pageTitle: "Lapin Futé settings", |
| 74 | + pageHeading: "Settings", | ||
| 74 | intro: "Choose the departures you want at hand on your Pebble.", | 75 | intro: "Choose the departures you want at hand on your Pebble.", |
| 75 | keyTitle: "PRIM access", | 76 | keyTitle: "PRIM access", |
| 76 | - keyStatusConfigured: "A key is configured on this phone.", | 77 | + keyStatusConfigured: "A key is saved on this phone. It is not verified against PRIM.", |
| 77 | keyStatusMissing: "No key is configured.", | 78 | keyStatusMissing: "No key is configured.", |
| 78 | keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.", | 79 | keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.", |
| 79 | keyLink: "Create a PRIM token", | 80 | keyLink: "Create a PRIM token", |
| 81 | + keyDocsLink: "Instructions", | ||
| 82 | + keyRequirement: "A personal PRIM token is required to retrieve live departures. You can prepare your favorites without one.", | ||
| 83 | + keyRequiredHint: "PRIM token required for live departures", | ||
| 80 | keyLabel: "Replace with a new key", | 84 | keyLabel: "Replace with a new key", |
| 81 | keyPlaceholder: "Paste a new key", | 85 | keyPlaceholder: "Paste a new key", |
| 82 | keyShow: "Show", | 86 | keyShow: "Show", |
| @@ -88,8 +92,13 @@ export const COPY = { | |||
| 88 | keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.", | 92 | keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.", |
| 89 | keyErrorNewline: "The key must not contain control characters.", | 93 | keyErrorNewline: "The key must not contain control characters.", |
| 90 | favoritesTitle: "Favorite departures", | 94 | favoritesTitle: "Favorite departures", |
| 95 | + favoriteUsed: "used", | ||
| 96 | + favoritesUsed: "used", | ||
| 97 | + favoriteAvailable: "available", | ||
| 98 | + favoritesAvailable: "available", | ||
| 91 | favoritesEmpty: "No favorites yet. Search for a stop to add one.", | 99 | favoritesEmpty: "No favorites yet. Search for a stop to add one.", |
| 92 | favoriteAddTitle: "Add a favorite", | 100 | favoriteAddTitle: "Add a favorite", |
| 101 | + favoriteAddCaption: "Stop, line and direction", | ||
| 93 | searchLabel: "Stop or station", | 102 | searchLabel: "Stop or station", |
| 94 | searchPlaceholder: "Search by name", | 103 | searchPlaceholder: "Search by name", |
| 95 | searchHint: "Enter at least 2 characters.", | 104 | searchHint: "Enter at least 2 characters.", |
| @@ -118,15 +127,20 @@ export const COPY = { | |||
| 118 | aboutBack: "Back to settings", | 127 | aboutBack: "Back to settings", |
| 119 | aboutTitle: "About Lapin Futé", | 128 | aboutTitle: "About Lapin Futé", |
| 120 | syncTitle: "Watch synchronization", | 129 | syncTitle: "Watch synchronization", |
| 130 | + syncCaption: "Transfer options", | ||
| 121 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", | 131 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", |
| 122 | forceSyncLabel: "Force a full synchronization at the next save", | 132 | forceSyncLabel: "Force a full synchronization at the next save", |
| 123 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", | 133 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", |
| 124 | }, | 134 | }, |
| 125 | fr: { | 135 | fr: { |
| 126 | pageTitle: "Réglages Lapin Futé", | 136 | pageTitle: "Réglages Lapin Futé", |
| 137 | + pageHeading: "Réglages", | ||
| 127 | intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.", | 138 | intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.", |
| 128 | keyTitle: "Accès PRIM", | 139 | keyTitle: "Accès PRIM", |
| 129 | - keyStatusConfigured: "Une clé est configurée sur ce téléphone.", | 140 | + keyStatusConfigured: "Une clé est enregistrée sur ce téléphone. Elle n’est pas vérifiée auprès de PRIM.", |
| 141 | + keyDocsLink: "Instructions", | ||
| 142 | + keyRequirement: "Un jeton PRIM personnel est obligatoire pour récupérer les prochains départs en temps réel. Vous pouvez préparer vos favoris sans jeton.", | ||
| 143 | + keyRequiredHint: "Jeton PRIM requis pour les départs en temps réel", | ||
| 130 | keyStatusMissing: "Aucune clé n’est configurée.", | 144 | keyStatusMissing: "Aucune clé n’est configurée.", |
| 131 | keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.", | 145 | keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.", |
| 132 | keyLink: "Créer un jeton PRIM", | 146 | keyLink: "Créer un jeton PRIM", |
| @@ -141,8 +155,13 @@ export const COPY = { | |||
| 141 | keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.", | 155 | keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.", |
| 142 | keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.", | 156 | keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.", |
| 143 | favoritesTitle: "Départs favoris", | 157 | favoritesTitle: "Départs favoris", |
| 158 | + favoriteUsed: "utilisé", | ||
| 159 | + favoritesUsed: "utilisés", | ||
| 160 | + favoriteAvailable: "disponible", | ||
| 161 | + favoritesAvailable: "disponibles", | ||
| 144 | favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", | 162 | favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", |
| 145 | favoriteAddTitle: "Ajouter un favori", | 163 | favoriteAddTitle: "Ajouter un favori", |
| 164 | + favoriteAddCaption: "Arrêt, ligne et direction", | ||
| 146 | searchLabel: "Arrêt ou gare", | 165 | searchLabel: "Arrêt ou gare", |
| 147 | searchPlaceholder: "Rechercher par nom", | 166 | searchPlaceholder: "Rechercher par nom", |
| 148 | searchHint: "Saisissez au moins 2 caractères.", | 167 | searchHint: "Saisissez au moins 2 caractères.", |
| @@ -171,6 +190,7 @@ export const COPY = { | |||
| 171 | aboutBack: "Retour aux réglages", | 190 | aboutBack: "Retour aux réglages", |
| 172 | aboutTitle: "À propos de Lapin Futé", | 191 | aboutTitle: "À propos de Lapin Futé", |
| 173 | syncTitle: "Synchronisation de la montre", | 192 | syncTitle: "Synchronisation de la montre", |
| 193 | + syncCaption: "Options de transfert", | ||
| 174 | syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.", | 194 | syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.", |
| 175 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", | 195 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", |
| 176 | saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.", | 196 | saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.", |
| @@ -238,7 +258,8 @@ export function copyPhoneFavorite(favorite) { | |||
| 238 | return copy; | 258 | return copy; |
| 239 | } | 259 | } |
| 240 | 260 | ||
| 241 | -const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"]; | 261 | +const PLACE_LINE_FIELDS = ["lineLabel", "lineColor", "lineTextColor"]; |
| 262 | +const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode", "lines"]; | ||
| 242 | const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"]; | 263 | const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"]; |
| 243 | const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]; | 264 | const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]; |
| 244 | 265 | ||
| @@ -248,12 +269,32 @@ function exactFields(value, allowed, required) { | |||
| 248 | && required.every((field) => Object.hasOwn(value, field)); | 269 | && required.every((field) => Object.hasOwn(value, field)); |
| 249 | } | 270 | } |
| 250 | 271 | ||
| 272 | +export function isPlaceLine(value) { | ||
| 273 | + return exactFields(value, PLACE_LINE_FIELDS, PLACE_LINE_FIELDS) | ||
| 274 | + && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) | ||
| 275 | + && isLineColor(value.lineColor) | ||
| 276 | + && isLineColor(value.lineTextColor); | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +// Producer-precomputed display lines for one search row: an entry per native | ||
| 280 | +// line with no per-line mode or ref. The list is required and nonempty; the | ||
| 281 | +// response byte bound (not a speculative count cap) limits its length. Holes | ||
| 282 | +// are visited by for...of, which Array.prototype.every would silently skip. | ||
| 283 | +function isPlaceLineList(lines) { | ||
| 284 | + if (!Array.isArray(lines) || lines.length === 0) return false; | ||
| 285 | + for (const line of lines) { | ||
| 286 | + if (!isPlaceLine(line)) return false; | ||
| 287 | + } | ||
| 288 | + return true; | ||
| 289 | +} | ||
| 290 | + | ||
| 251 | export function isPlaceSearchItem(value) { | 291 | export function isPlaceSearchItem(value) { |
| 252 | - return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"]) | 292 | + return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode", "lines"]) |
| 253 | && boundedString(value.placeId, LIMITS.idUtf8Bytes) | 293 | && boundedString(value.placeId, LIMITS.idUtf8Bytes) |
| 254 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) | 294 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 255 | && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes)) | 295 | && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes)) |
| 256 | - && TRANSPORT_MODES.includes(value.mode); | 296 | + && TRANSPORT_MODES.includes(value.mode) |
| 297 | + && isPlaceLineList(value.lines); | ||
| 257 | } | 298 | } |
| 258 | 299 | ||
| 259 | export function isPlaceSearchResult(value) { | 300 | export function isPlaceSearchResult(value) { |
modified
packages/config-page/src/config-page.js +230 -31 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/styles/config-page.css +274 -207 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/test/catalog-client.test.js +42 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/test/config-page.test.ts +58 -2 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/src/index.ts +34 -6 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/test/contracts.test.ts +42 -1 Large diff collapsed to keep the page fast — load it when you need it.