feat: migrate to phone-only data and phone-prepared watch displays #17
Merged@github-anthodev wants to merge
feature/redesign-watch-experience/watch/16 into developMerged as
a46bf24ac09c.100 files changed
added
.env.example +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +# Lapin Futé operator environment (phone-only architecture: no backend server, | |
| 2 | +# no server deployment variables). Copy to .env for local operations: | |
| 3 | +# node --env-file=.env npm run catalog:refresh | |
| 4 | +# Secrets live only in .env (gitignored); scripts never print or export them. | |
| 5 | + | |
| 6 | +# Required by scripts/refresh-catalog.mjs: IDFM dataset token used to download | |
| 7 | +# and re-verify the GTFS/catalog source datasets. | |
| 8 | +IDFM_DATASET_TOKEN= | |
| 9 | + | |
| 10 | +# Required by scripts/probe-catalog.mjs: personal PRIM key for the one-call | |
| 11 | +# live stop-monitoring probe (one request per transport mode at most). | |
| 12 | +PRIM_API_KEY= | |
| 13 | + | |
| 14 | +# Optional paths (defaults shown): | |
| 15 | +# CATALOG_PATH=var/catalog/catalog.sqlite | |
| 16 | +# CATALOG_STATIC_PATH=var/catalog/static | |
| 17 | + | |
| 18 | +# Optional, scripts/build.mjs: absolute HTTPS URL of the statically hosted | |
| 19 | +# configuration site, baked into the phone companion bootstrap. | |
| 20 | +# LAPIN_FUTE_CONFIG_URL= | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +# Lapin Futé operator environment (phone-only architecture: no backend server, | ||
| 2 | +# no server deployment variables). Copy to .env for local operations: | ||
| 3 | +# node --env-file=.env npm run catalog:refresh | ||
| 4 | +# Secrets live only in .env (gitignored); scripts never print or export them. | ||
| 5 | + | ||
| 6 | +# Required by scripts/refresh-catalog.mjs: IDFM dataset token used to download | ||
| 7 | +# and re-verify the GTFS/catalog source datasets. | ||
| 8 | +IDFM_DATASET_TOKEN= | ||
| 9 | + | ||
| 10 | +# Required by scripts/probe-catalog.mjs: personal PRIM key for the one-call | ||
| 11 | +# live stop-monitoring probe (one request per transport mode at most). | ||
| 12 | +PRIM_API_KEY= | ||
| 13 | + | ||
| 14 | +# Optional paths (defaults shown): | ||
| 15 | +# CATALOG_PATH=var/catalog/catalog.sqlite | ||
| 16 | +# CATALOG_STATIC_PATH=var/catalog/static | ||
| 17 | + | ||
| 18 | +# Optional, scripts/build.mjs: absolute HTTPS URL of the statically hosted | ||
| 19 | +# configuration site, baked into the phone companion bootstrap. | ||
| 20 | +# LAPIN_FUTE_CONFIG_URL= | ||
modified
.gitignore +1 -0 | @@ -5,6 +5,7 @@ node_modules/ | ||
| 5 | 5 | var/ |
| 6 | 6 | packages/watch/build/ |
| 7 | 7 | packages/watch/src/pkjs/ |
| 8 | +packages/watch/src/generated/ | |
| 8 | 9 | docs/ |
| 9 | 10 | *.pbw |
| 10 | 11 | .DS_Store |
| @@ -5,6 +5,7 @@ node_modules/ | |||
| 5 | var/ | 5 | var/ |
| 6 | packages/watch/build/ | 6 | packages/watch/build/ |
| 7 | packages/watch/src/pkjs/ | 7 | packages/watch/src/pkjs/ |
| 8 | +packages/watch/src/generated/ | ||
| 8 | docs/ | 9 | docs/ |
| 9 | *.pbw | 10 | *.pbw |
| 10 | .DS_Store | 11 | .DS_Store |
modified
README.md +30 -42 | @@ -20,46 +20,43 @@ | ||
| 20 | 20 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 21 | 21 | <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript"> |
| 22 | 22 | <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24"> |
| 23 | - <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status"> | |
| 23 | + <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status"> | |
| 24 | 24 | </p> |
| 25 | 25 | |
| 26 | -> [!IMPORTANT] | |
| 27 | -> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host. | |
| 28 | - | |
| 29 | 26 | ## About |
| 30 | 27 | |
| 31 | 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. |
| 32 | 29 | |
| 33 | 30 | ```text |
| 34 | -Minimal Piu fixture view | |
| 31 | +Departure and traffic views | |
| 35 | 32 | │ symbolic AppMessage aliases |
| 36 | 33 | ▼ |
| 37 | -PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM | |
| 34 | +PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM | |
| 38 | 35 | │ |
| 39 | - └── phone-local favorites + personal key | |
| 36 | + └── phone-local favorites (six) + personal key | |
| 40 | 37 | ``` |
| 41 | 38 | |
| 42 | -**The watch never receives the personal PRIM key and never starts network or background work.** | |
| 39 | +**The watch never receives the personal PRIM key and never starts network or background requests.** | |
| 43 | 40 | |
| 44 | 41 | ## Highlights |
| 45 | 42 | |
| 46 | -- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap. | |
| 47 | -- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`. | |
| 48 | -- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state. | |
| 49 | -- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR. | |
| 50 | -- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two. | |
| 51 | -- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary. | |
| 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. | |
| 52 | 49 | |
| 53 | 50 | ## Architecture |
| 54 | 51 | |
| 55 | 52 | | Component | Owns | Must not | |
| 56 | 53 | |---|---|---| |
| 57 | -| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences | | |
| 58 | -| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening | | |
| 59 | -| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage | | |
| 60 | -| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage | | |
| 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 | | |
| 61 | 58 | |
| 62 | -The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`. | |
| 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. | |
| 63 | 60 | |
| 64 | 61 | ## Targets and requirements |
| 65 | 62 | |
| @@ -70,14 +67,12 @@ The embedded modules are `main`, `controller`, `model`, `protocol`, `message-que | ||
| 70 | 67 | | Pebble SDK | 4.33.1 | |
| 71 | 68 | | Pebble CLI | `pebble-tool` 5.0.40 | |
| 72 | 69 | | Host runtime | Node.js 24.18.0 | |
| 73 | -| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media | | |
| 74 | - | |
| 75 | -See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands. | |
| 70 | +| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media | | |
| 76 | 71 | |
| 77 | 72 | |
| 78 | 73 | ## Build from source |
| 79 | 74 | |
| 80 | -Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain. | |
| 75 | +Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain. | |
| 81 | 76 | |
| 82 | 77 | ```sh |
| 83 | 78 | npm test |
| @@ -85,19 +80,20 @@ npm run measure:radio | ||
| 85 | 80 | npm run build |
| 86 | 81 | ``` |
| 87 | 82 | |
| 88 | -`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs: | |
| 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: | |
| 89 | 84 | |
| 90 | 85 | ```sh |
| 91 | 86 | LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ |
| 92 | -LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \ | |
| 93 | 87 | npm run build |
| 94 | 88 | ``` |
| 95 | 89 | |
| 96 | 90 | Personal PRIM keys are never build inputs. |
| 97 | 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 | + | |
| 98 | 94 | ## Privacy |
| 99 | 95 | |
| 100 | -The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence. | |
| 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. | |
| 101 | 97 | |
| 102 | 98 | Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. |
| 103 | 99 | |
| @@ -105,26 +101,18 @@ Lapin Futé has no telemetry. The watch has no network module and starts no peri | ||
| 105 | 101 | |
| 106 | 102 | | Gate | Budget | |
| 107 | 103 | |---|---| |
| 108 | -| Alloy app binary | At most 120 KiB on each target | | |
| 109 | -| Loaded image and RAM | At most 56 KiB each | | |
| 110 | -| XS arenas | At least 25% free at the worst measured point | | |
| 111 | -| Settled XS drift | At most 1 KiB over the final ten of 50 cycles | | |
| 112 | -| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes | | |
| 113 | -| Watch request | At most 192 encoded bytes | | |
| 104 | +| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 | | |
| 114 | 105 | | Phone-to-watch dictionary | At most 768 encoded bytes | |
| 106 | +| Watch-to-phone dictionary | At most 192 encoded bytes | | |
| 115 | 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 | | |
| 116 | 111 | |
| 117 | -The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md). | |
| 118 | - | |
| 119 | -## Documentation | |
| 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. | |
| 120 | 113 | |
| 121 | -- [Contracts](docs/contracts.md) | |
| 122 | -- [Toolchain](docs/toolchain.md) | |
| 123 | -- [Platforms](docs/platforms.md) | |
| 124 | -- [Performance and battery](docs/performance.md) | |
| 125 | -- [Localization](docs/localization.md) | |
| 126 | -- [Evidence ledger](docs/evidence.md) | |
| 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. | |
| 127 | 115 | |
| 128 | 116 | ## Contributing |
| 129 | 117 | |
| 130 | -Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates. | |
| 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. | |
| @@ -20,46 +20,43 @@ | |||
| 20 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> | 20 | <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"> | 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"> | 22 | <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24"> |
| 23 | - <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status"> | 23 | + <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status"> |
| 24 | </p> | 24 | </p> |
| 25 | 25 | ||
| 26 | -> [!IMPORTANT] | ||
| 27 | -> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host. | ||
| 28 | - | ||
| 29 | ## About | 26 | ## About |
| 30 | 27 | ||
| 31 | 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. | 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. |
| 32 | 29 | ||
| 33 | ```text | 30 | ```text |
| 34 | -Minimal Piu fixture view | 31 | +Departure and traffic views |
| 35 | │ symbolic AppMessage aliases | 32 | │ symbolic AppMessage aliases |
| 36 | ▼ | 33 | ▼ |
| 37 | -PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM | 34 | +PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM |
| 38 | │ | 35 | │ |
| 39 | - └── phone-local favorites + personal key | 36 | + └── phone-local favorites (six) + personal key |
| 40 | ``` | 37 | ``` |
| 41 | 38 | ||
| 42 | -**The watch never receives the personal PRIM key and never starts network or background work.** | 39 | +**The watch never receives the personal PRIM key and never starts network or background requests.** |
| 43 | 40 | ||
| 44 | ## Highlights | 41 | ## Highlights |
| 45 | 42 | ||
| 46 | -- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap. | 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. |
| 47 | -- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`. | 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. |
| 48 | -- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state. | 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. |
| 49 | -- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR. | 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. |
| 50 | -- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two. | 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. |
| 51 | -- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary. | 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. |
| 52 | 49 | ||
| 53 | ## Architecture | 50 | ## Architecture |
| 54 | 51 | ||
| 55 | | Component | Owns | Must not | | 52 | | Component | Owns | Must not | |
| 56 | |---|---|---| | 53 | |---|---|---| |
| 57 | -| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences | | 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 | |
| 58 | -| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening | | 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 | |
| 59 | -| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage | | 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 | |
| 60 | -| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage | | 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 | |
| 61 | 58 | ||
| 62 | -The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`. | 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. |
| 63 | 60 | ||
| 64 | ## Targets and requirements | 61 | ## Targets and requirements |
| 65 | 62 | ||
| @@ -70,14 +67,12 @@ The embedded modules are `main`, `controller`, `model`, `protocol`, `message-que | |||
| 70 | | Pebble SDK | 4.33.1 | | 67 | | Pebble SDK | 4.33.1 | |
| 71 | | Pebble CLI | `pebble-tool` 5.0.40 | | 68 | | Pebble CLI | `pebble-tool` 5.0.40 | |
| 72 | | Host runtime | Node.js 24.18.0 | | 69 | | Host runtime | Node.js 24.18.0 | |
| 73 | -| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media | | 70 | +| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media | |
| 74 | - | ||
| 75 | -See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands. | ||
| 76 | 71 | ||
| 77 | 72 | ||
| 78 | ## Build from source | 73 | ## Build from source |
| 79 | 74 | ||
| 80 | -Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain. | 75 | +Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain. |
| 81 | 76 | ||
| 82 | ```sh | 77 | ```sh |
| 83 | npm test | 78 | npm test |
| @@ -85,19 +80,20 @@ npm run measure:radio | |||
| 85 | npm run build | 80 | npm run build |
| 86 | ``` | 81 | ``` |
| 87 | 82 | ||
| 88 | -`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs: | 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: |
| 89 | 84 | ||
| 90 | ```sh | 85 | ```sh |
| 91 | LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ | 86 | LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ |
| 92 | -LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \ | ||
| 93 | npm run build | 87 | npm run build |
| 94 | ``` | 88 | ``` |
| 95 | 89 | ||
| 96 | Personal PRIM keys are never build inputs. | 90 | Personal PRIM keys are never build inputs. |
| 97 | 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 | + | ||
| 98 | ## Privacy | 94 | ## Privacy |
| 99 | 95 | ||
| 100 | -The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence. | 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. |
| 101 | 97 | ||
| 102 | Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. | 98 | Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. |
| 103 | 99 | ||
| @@ -105,26 +101,18 @@ Lapin Futé has no telemetry. The watch has no network module and starts no peri | |||
| 105 | 101 | ||
| 106 | | Gate | Budget | | 102 | | Gate | Budget | |
| 107 | |---|---| | 103 | |---|---| |
| 108 | -| Alloy app binary | At most 120 KiB on each target | | 104 | +| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 | |
| 109 | -| Loaded image and RAM | At most 56 KiB each | | ||
| 110 | -| XS arenas | At least 25% free at the worst measured point | | ||
| 111 | -| Settled XS drift | At most 1 KiB over the final ten of 50 cycles | | ||
| 112 | -| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes | | ||
| 113 | -| Watch request | At most 192 encoded bytes | | ||
| 114 | | Phone-to-watch dictionary | At most 768 encoded bytes | | 105 | | Phone-to-watch dictionary | At most 768 encoded bytes | |
| 106 | +| Watch-to-phone dictionary | At most 192 encoded bytes | | ||
| 115 | | Watch unsent queue | Four messages, at most once, one write per `onWritable` | | 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 | | ||
| 116 | 111 | ||
| 117 | -The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md). | 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. |
| 118 | - | ||
| 119 | -## Documentation | ||
| 120 | 113 | ||
| 121 | -- [Contracts](docs/contracts.md) | 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. |
| 122 | -- [Toolchain](docs/toolchain.md) | ||
| 123 | -- [Platforms](docs/platforms.md) | ||
| 124 | -- [Performance and battery](docs/performance.md) | ||
| 125 | -- [Localization](docs/localization.md) | ||
| 126 | -- [Evidence ledger](docs/evidence.md) | ||
| 127 | 115 | ||
| 128 | ## Contributing | 116 | ## Contributing |
| 129 | 117 | ||
| 130 | -Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates. | 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. |
modified
fixtures/catalog/idfm-v1.json +36 -37 | @@ -75,12 +75,11 @@ | ||
| 75 | 75 | { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" }, |
| 76 | 76 | { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" }, |
| 77 | 77 | { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" }, |
| 78 | - { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | |
| 79 | - { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" }, | |
| 80 | - { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | |
| 81 | - { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" }, | |
| 82 | - { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" }, | |
| 83 | - { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" }, | |
| 78 | + { "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_name": "Châtelet RER est", "parent_station": "IDFM:ZDC-RER" }, | |
| 79 | + { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" }, | |
| 80 | + { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" }, | |
| 81 | + { "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:ZDC-TRANSILIEN" }, | |
| 82 | + { "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" }, | |
| 84 | 83 | { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" }, |
| 85 | 84 | { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" } |
| 86 | 85 | ], |
| @@ -109,35 +108,35 @@ | ||
| 109 | 108 | { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] }, |
| 110 | 109 | { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" }, |
| 111 | 110 | { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" }, |
| 112 | - { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" }, | |
| 113 | - { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" }, | |
| 114 | - { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" }, | |
| 115 | - { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" }, | |
| 116 | - { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" }, | |
| 117 | - { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" }, | |
| 111 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" }, | |
| 112 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" }, | |
| 113 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" }, | |
| 114 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" }, | |
| 115 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" }, | |
| 116 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" }, | |
| 118 | 117 | { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" }, |
| 119 | 118 | { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" } |
| 120 | 119 | ], |
| 121 | 120 | "objectCodes": [ |
| 122 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" }, | |
| 121 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "source", "object_code": "FR::Quay:ART-BUS-1:FR1" }, | |
| 123 | 122 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" }, |
| 124 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" }, | |
| 123 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "source", "object_code": "FR::Quay:ART-BUS-2:FR1" }, | |
| 125 | 124 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" }, |
| 126 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" }, | |
| 125 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "source", "object_code": "FR::Quay:ART-BUS-MISMATCH:FR1" }, | |
| 127 | 126 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" }, |
| 128 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" }, | |
| 127 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-EAST:FR1" }, | |
| 129 | 128 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" }, |
| 130 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] }, | |
| 129 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-WEST:FR1", "revisions": ["fixture-2026-08-a"] }, | |
| 131 | 130 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] }, |
| 132 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" }, | |
| 131 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "source", "object_code": "FR::Quay:ART-TRAM:FR1" }, | |
| 133 | 132 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" }, |
| 134 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | |
| 135 | - { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" }, | |
| 136 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | |
| 137 | - { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" }, | |
| 138 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" }, | |
| 139 | - { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" }, | |
| 140 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" }, | |
| 133 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" }, | |
| 134 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" }, | |
| 135 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" }, | |
| 136 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" }, | |
| 137 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-TRANSILIEN:FR1" }, | |
| 138 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" }, | |
| 139 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "source", "object_code": "FR::Quay:ART-FUNICULAR:FR1" }, | |
| 141 | 140 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" } |
| 142 | 141 | ], |
| 143 | 142 | "arretsLignes": [ |
| @@ -147,21 +146,21 @@ | ||
| 147 | 146 | { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" }, |
| 148 | 147 | { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" }, |
| 149 | 148 | { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" }, |
| 150 | - { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" }, | |
| 151 | - { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" }, | |
| 152 | - { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" }, | |
| 149 | + { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" }, | |
| 150 | + { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" }, | |
| 151 | + { "id": "IDFM:C500", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" }, | |
| 153 | 152 | { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" } |
| 154 | 153 | ], |
| 155 | 154 | "perimeter": [ |
| 156 | - { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | |
| 157 | - { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | |
| 158 | - { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" }, | |
| 159 | - { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" }, | |
| 160 | - { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] }, | |
| 161 | - { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" }, | |
| 162 | - { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" }, | |
| 163 | - { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] }, | |
| 164 | - { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" } | |
| 155 | + { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | |
| 156 | + { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | |
| 157 | + { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" }, | |
| 158 | + { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" }, | |
| 159 | + { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] }, | |
| 160 | + { "line": "STIF:Line::C300:", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:ART-TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" }, | |
| 161 | + { "line": "STIF:Line::C400:", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-RER:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" }, | |
| 162 | + { "line": "STIF:Line::C500:", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-TRANSILIEN:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a","fixture-2026-08-b"] }, | |
| 163 | + { "line": "STIF:Line::C600:", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:ART-FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" } | |
| 165 | 164 | ] |
| 166 | 165 | } |
| 167 | 166 | } |
| @@ -75,12 +75,11 @@ | |||
| 75 | { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" }, | 75 | { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" }, |
| 76 | { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" }, | 76 | { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" }, |
| 77 | { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" }, | 77 | { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" }, |
| 78 | - { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | 78 | + { "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_name": "Châtelet RER est", "parent_station": "IDFM:ZDC-RER" }, |
| 79 | - { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" }, | 79 | + { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" }, |
| 80 | - { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" }, | 80 | + { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" }, |
| 81 | - { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" }, | 81 | + { "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:ZDC-TRANSILIEN" }, |
| 82 | - { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" }, | 82 | + { "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" }, |
| 83 | - { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" }, | ||
| 84 | { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" }, | 83 | { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" }, |
| 85 | { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" } | 84 | { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" } |
| 86 | ], | 85 | ], |
| @@ -109,35 +108,35 @@ | |||
| 109 | { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] }, | 108 | { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] }, |
| 110 | { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" }, | 109 | { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" }, |
| 111 | { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" }, | 110 | { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" }, |
| 112 | - { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" }, | 111 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" }, |
| 113 | - { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" }, | 112 | + { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" }, |
| 114 | - { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" }, | 113 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" }, |
| 115 | - { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" }, | 114 | + { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" }, |
| 116 | - { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" }, | 115 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" }, |
| 117 | - { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" }, | 116 | + { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" }, |
| 118 | { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" }, | 117 | { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" }, |
| 119 | { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" } | 118 | { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" } |
| 120 | ], | 119 | ], |
| 121 | "objectCodes": [ | 120 | "objectCodes": [ |
| 122 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" }, | 121 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "source", "object_code": "FR::Quay:ART-BUS-1:FR1" }, |
| 123 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" }, | 122 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" }, |
| 124 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" }, | 123 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "source", "object_code": "FR::Quay:ART-BUS-2:FR1" }, |
| 125 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" }, | 124 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" }, |
| 126 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" }, | 125 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "source", "object_code": "FR::Quay:ART-BUS-MISMATCH:FR1" }, |
| 127 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" }, | 126 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" }, |
| 128 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" }, | 127 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-EAST:FR1" }, |
| 129 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" }, | 128 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" }, |
| 130 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] }, | 129 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-WEST:FR1", "revisions": ["fixture-2026-08-a"] }, |
| 131 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] }, | 130 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] }, |
| 132 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" }, | 131 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "source", "object_code": "FR::Quay:ART-TRAM:FR1" }, |
| 133 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" }, | 132 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" }, |
| 134 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | 133 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" }, |
| 135 | - { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" }, | 134 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" }, |
| 136 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" }, | 135 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" }, |
| 137 | - { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" }, | 136 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" }, |
| 138 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" }, | 137 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-TRANSILIEN:FR1" }, |
| 139 | - { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" }, | 138 | + { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" }, |
| 140 | - { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" }, | 139 | + { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "source", "object_code": "FR::Quay:ART-FUNICULAR:FR1" }, |
| 141 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" } | 140 | { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" } |
| 142 | ], | 141 | ], |
| 143 | "arretsLignes": [ | 142 | "arretsLignes": [ |
| @@ -147,21 +146,21 @@ | |||
| 147 | { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" }, | 146 | { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" }, |
| 148 | { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" }, | 147 | { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" }, |
| 149 | { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" }, | 148 | { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" }, |
| 150 | - { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" }, | 149 | + { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" }, |
| 151 | - { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" }, | 150 | + { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" }, |
| 152 | - { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" }, | 151 | + { "id": "IDFM:C500", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" }, |
| 153 | { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" } | 152 | { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" } |
| 154 | ], | 153 | ], |
| 155 | "perimeter": [ | 154 | "perimeter": [ |
| 156 | - { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | 155 | + { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, |
| 157 | - { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, | 156 | + { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" }, |
| 158 | - { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" }, | 157 | + { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" }, |
| 159 | - { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" }, | 158 | + { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" }, |
| 160 | - { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] }, | 159 | + { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] }, |
| 161 | - { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" }, | 160 | + { "line": "STIF:Line::C300:", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:ART-TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" }, |
| 162 | - { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" }, | 161 | + { "line": "STIF:Line::C400:", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-RER:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" }, |
| 163 | - { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] }, | 162 | + { "line": "STIF:Line::C500:", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-TRANSILIEN:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a","fixture-2026-08-b"] }, |
| 164 | - { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" } | 163 | + { "line": "STIF:Line::C600:", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:ART-FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" } |
| 165 | ] | 164 | ] |
| 166 | } | 165 | } |
| 167 | } | 166 | } |
added
fixtures/traffic/global.json +146 -0 | new file mode 100644 | ||
| @@ -0,0 +1,146 @@ | ||
| 1 | +{ | |
| 2 | + "disruptions": [ | |
| 3 | + { | |
| 4 | + "id": "delay-active", | |
| 5 | + "applicationPeriods": [ | |
| 6 | + { "begin": "20260115T095900", "end": "20260115T110000" } | |
| 7 | + ], | |
| 8 | + "lastUpdate": "20260115T095500", | |
| 9 | + "cause": "PERTURBATION", | |
| 10 | + "severity": "PERTURBEE", | |
| 11 | + "title": "Métro 2 : ralentissements", | |
| 12 | + "message": "<p>Le trafic est ralenti & les temps d’attente sont allongés.</p>" | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + "id": "information-active", | |
| 16 | + "applicationPeriods": [ | |
| 17 | + { "begin": "20260115T090000", "end": "20260115T120000" } | |
| 18 | + ], | |
| 19 | + "lastUpdate": "20260115T094500", | |
| 20 | + "cause": "INFORMATION", | |
| 21 | + "severity": "INFORMATION", | |
| 22 | + "title": "Information voyageurs", | |
| 23 | + "message": "<p>La circulation a repris normalement.</p>" | |
| 24 | + }, | |
| 25 | + { | |
| 26 | + "id": "stopped-active", | |
| 27 | + "applicationPeriods": [ | |
| 28 | + { "begin": "20260115T093000", "end": "20260115T103000" } | |
| 29 | + ], | |
| 30 | + "lastUpdate": "20260115T095800", | |
| 31 | + "cause": "PERTURBATION", | |
| 32 | + "severity": { "name": "BLOQUANTE", "effect": "NO_SERVICE" }, | |
| 33 | + "title": "RER A : trafic interrompu", | |
| 34 | + "message": "<p>Le trafic est interrompu entre les deux terminus.</p>" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "id": "blocking-unclassified", | |
| 38 | + "applicationPeriods": [ | |
| 39 | + { "begin": "20260115T093000", "end": "20260115T103000" } | |
| 40 | + ], | |
| 41 | + "lastUpdate": "20260115T095700", | |
| 42 | + "cause": "PERTURBATION", | |
| 43 | + "severity": "BLOQUANTE", | |
| 44 | + "title": "Tram T3 : incident", | |
| 45 | + "message": "<p>Une perturbation est en cours.</p>" | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "id": "future-delay", | |
| 49 | + "applicationPeriods": [ | |
| 50 | + { "begin": "20260115T120000", "end": "20260115T130000" } | |
| 51 | + ], | |
| 52 | + "lastUpdate": "20260115T090000", | |
| 53 | + "cause": "TRAVAUX", | |
| 54 | + "severity": "PERTURBEE", | |
| 55 | + "title": "Travaux prévus", | |
| 56 | + "message": "<p>Des travaux sont prévus plus tard.</p>" | |
| 57 | + } | |
| 58 | + ], | |
| 59 | + "lines": [ | |
| 60 | + { | |
| 61 | + "id": "line:IDFM:C100", | |
| 62 | + "name": "1", | |
| 63 | + "shortName": "1", | |
| 64 | + "mode": "Metro", | |
| 65 | + "networkId": "network:IDFM:Operator_100", | |
| 66 | + "impactedObjects": [] | |
| 67 | + }, | |
| 68 | + { | |
| 69 | + "id": "line:IDFM:C200", | |
| 70 | + "name": "2", | |
| 71 | + "shortName": "2", | |
| 72 | + "mode": "Metro", | |
| 73 | + "networkId": "network:IDFM:Operator_100", | |
| 74 | + "impactedObjects": [ | |
| 75 | + { | |
| 76 | + "type": "line", | |
| 77 | + "id": "line:IDFM:C200", | |
| 78 | + "name": "2", | |
| 79 | + "disruptionIds": ["delay-active", "information-active", "future-delay"] | |
| 80 | + } | |
| 81 | + ] | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "id": "line:IDFM:C300", | |
| 85 | + "name": "A", | |
| 86 | + "shortName": "A", | |
| 87 | + "mode": "RapidTransit", | |
| 88 | + "networkId": "network:IDFM:71", | |
| 89 | + "impactedObjects": [ | |
| 90 | + { | |
| 91 | + "type": "line", | |
| 92 | + "id": "line:IDFM:C300", | |
| 93 | + "name": "A", | |
| 94 | + "disruptionIds": ["delay-active", "blocking-unclassified", "stopped-active"] | |
| 95 | + } | |
| 96 | + ] | |
| 97 | + }, | |
| 98 | + { | |
| 99 | + "id": "line:IDFM:C400", | |
| 100 | + "name": "T3", | |
| 101 | + "shortName": "T3", | |
| 102 | + "mode": "Tramway", | |
| 103 | + "networkId": "network:IDFM:Operator_100", | |
| 104 | + "impactedObjects": [ | |
| 105 | + { | |
| 106 | + "type": "line", | |
| 107 | + "id": "line:IDFM:C400", | |
| 108 | + "name": "T3", | |
| 109 | + "disruptionIds": ["delay-active", "blocking-unclassified"] | |
| 110 | + } | |
| 111 | + ] | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + "id": "line:IDFM:C500", | |
| 115 | + "name": "5", | |
| 116 | + "shortName": "5", | |
| 117 | + "mode": "Metro", | |
| 118 | + "networkId": "network:IDFM:Operator_100", | |
| 119 | + "impactedObjects": [ | |
| 120 | + { | |
| 121 | + "type": "line", | |
| 122 | + "id": "line:IDFM:C500", | |
| 123 | + "name": "5", | |
| 124 | + "disruptionIds": ["information-active"] | |
| 125 | + } | |
| 126 | + ] | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "id": "line:IDFM:C600", | |
| 130 | + "name": "6", | |
| 131 | + "shortName": "6", | |
| 132 | + "mode": "Metro", | |
| 133 | + "networkId": "network:IDFM:Operator_100", | |
| 134 | + "impactedObjects": [ | |
| 135 | + { | |
| 136 | + "type": "stop_point", | |
| 137 | + "id": "stop_point:IDFM:12345", | |
| 138 | + "name": "Un arrêt", | |
| 139 | + "disruptionIds": ["delay-active"] | |
| 140 | + } | |
| 141 | + ] | |
| 142 | + } | |
| 143 | + ], | |
| 144 | + "lastUpdatedDate": "2026-01-15T09:00:00.000Z", | |
| 145 | + "unknownFutureField": { "accepted": true } | |
| 146 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,146 @@ | |||
| 1 | +{ | ||
| 2 | + "disruptions": [ | ||
| 3 | + { | ||
| 4 | + "id": "delay-active", | ||
| 5 | + "applicationPeriods": [ | ||
| 6 | + { "begin": "20260115T095900", "end": "20260115T110000" } | ||
| 7 | + ], | ||
| 8 | + "lastUpdate": "20260115T095500", | ||
| 9 | + "cause": "PERTURBATION", | ||
| 10 | + "severity": "PERTURBEE", | ||
| 11 | + "title": "Métro 2 : ralentissements", | ||
| 12 | + "message": "<p>Le trafic est ralenti & les temps d’attente sont allongés.</p>" | ||
| 13 | + }, | ||
| 14 | + { | ||
| 15 | + "id": "information-active", | ||
| 16 | + "applicationPeriods": [ | ||
| 17 | + { "begin": "20260115T090000", "end": "20260115T120000" } | ||
| 18 | + ], | ||
| 19 | + "lastUpdate": "20260115T094500", | ||
| 20 | + "cause": "INFORMATION", | ||
| 21 | + "severity": "INFORMATION", | ||
| 22 | + "title": "Information voyageurs", | ||
| 23 | + "message": "<p>La circulation a repris normalement.</p>" | ||
| 24 | + }, | ||
| 25 | + { | ||
| 26 | + "id": "stopped-active", | ||
| 27 | + "applicationPeriods": [ | ||
| 28 | + { "begin": "20260115T093000", "end": "20260115T103000" } | ||
| 29 | + ], | ||
| 30 | + "lastUpdate": "20260115T095800", | ||
| 31 | + "cause": "PERTURBATION", | ||
| 32 | + "severity": { "name": "BLOQUANTE", "effect": "NO_SERVICE" }, | ||
| 33 | + "title": "RER A : trafic interrompu", | ||
| 34 | + "message": "<p>Le trafic est interrompu entre les deux terminus.</p>" | ||
| 35 | + }, | ||
| 36 | + { | ||
| 37 | + "id": "blocking-unclassified", | ||
| 38 | + "applicationPeriods": [ | ||
| 39 | + { "begin": "20260115T093000", "end": "20260115T103000" } | ||
| 40 | + ], | ||
| 41 | + "lastUpdate": "20260115T095700", | ||
| 42 | + "cause": "PERTURBATION", | ||
| 43 | + "severity": "BLOQUANTE", | ||
| 44 | + "title": "Tram T3 : incident", | ||
| 45 | + "message": "<p>Une perturbation est en cours.</p>" | ||
| 46 | + }, | ||
| 47 | + { | ||
| 48 | + "id": "future-delay", | ||
| 49 | + "applicationPeriods": [ | ||
| 50 | + { "begin": "20260115T120000", "end": "20260115T130000" } | ||
| 51 | + ], | ||
| 52 | + "lastUpdate": "20260115T090000", | ||
| 53 | + "cause": "TRAVAUX", | ||
| 54 | + "severity": "PERTURBEE", | ||
| 55 | + "title": "Travaux prévus", | ||
| 56 | + "message": "<p>Des travaux sont prévus plus tard.</p>" | ||
| 57 | + } | ||
| 58 | + ], | ||
| 59 | + "lines": [ | ||
| 60 | + { | ||
| 61 | + "id": "line:IDFM:C100", | ||
| 62 | + "name": "1", | ||
| 63 | + "shortName": "1", | ||
| 64 | + "mode": "Metro", | ||
| 65 | + "networkId": "network:IDFM:Operator_100", | ||
| 66 | + "impactedObjects": [] | ||
| 67 | + }, | ||
| 68 | + { | ||
| 69 | + "id": "line:IDFM:C200", | ||
| 70 | + "name": "2", | ||
| 71 | + "shortName": "2", | ||
| 72 | + "mode": "Metro", | ||
| 73 | + "networkId": "network:IDFM:Operator_100", | ||
| 74 | + "impactedObjects": [ | ||
| 75 | + { | ||
| 76 | + "type": "line", | ||
| 77 | + "id": "line:IDFM:C200", | ||
| 78 | + "name": "2", | ||
| 79 | + "disruptionIds": ["delay-active", "information-active", "future-delay"] | ||
| 80 | + } | ||
| 81 | + ] | ||
| 82 | + }, | ||
| 83 | + { | ||
| 84 | + "id": "line:IDFM:C300", | ||
| 85 | + "name": "A", | ||
| 86 | + "shortName": "A", | ||
| 87 | + "mode": "RapidTransit", | ||
| 88 | + "networkId": "network:IDFM:71", | ||
| 89 | + "impactedObjects": [ | ||
| 90 | + { | ||
| 91 | + "type": "line", | ||
| 92 | + "id": "line:IDFM:C300", | ||
| 93 | + "name": "A", | ||
| 94 | + "disruptionIds": ["delay-active", "blocking-unclassified", "stopped-active"] | ||
| 95 | + } | ||
| 96 | + ] | ||
| 97 | + }, | ||
| 98 | + { | ||
| 99 | + "id": "line:IDFM:C400", | ||
| 100 | + "name": "T3", | ||
| 101 | + "shortName": "T3", | ||
| 102 | + "mode": "Tramway", | ||
| 103 | + "networkId": "network:IDFM:Operator_100", | ||
| 104 | + "impactedObjects": [ | ||
| 105 | + { | ||
| 106 | + "type": "line", | ||
| 107 | + "id": "line:IDFM:C400", | ||
| 108 | + "name": "T3", | ||
| 109 | + "disruptionIds": ["delay-active", "blocking-unclassified"] | ||
| 110 | + } | ||
| 111 | + ] | ||
| 112 | + }, | ||
| 113 | + { | ||
| 114 | + "id": "line:IDFM:C500", | ||
| 115 | + "name": "5", | ||
| 116 | + "shortName": "5", | ||
| 117 | + "mode": "Metro", | ||
| 118 | + "networkId": "network:IDFM:Operator_100", | ||
| 119 | + "impactedObjects": [ | ||
| 120 | + { | ||
| 121 | + "type": "line", | ||
| 122 | + "id": "line:IDFM:C500", | ||
| 123 | + "name": "5", | ||
| 124 | + "disruptionIds": ["information-active"] | ||
| 125 | + } | ||
| 126 | + ] | ||
| 127 | + }, | ||
| 128 | + { | ||
| 129 | + "id": "line:IDFM:C600", | ||
| 130 | + "name": "6", | ||
| 131 | + "shortName": "6", | ||
| 132 | + "mode": "Metro", | ||
| 133 | + "networkId": "network:IDFM:Operator_100", | ||
| 134 | + "impactedObjects": [ | ||
| 135 | + { | ||
| 136 | + "type": "stop_point", | ||
| 137 | + "id": "stop_point:IDFM:12345", | ||
| 138 | + "name": "Un arrêt", | ||
| 139 | + "disruptionIds": ["delay-active"] | ||
| 140 | + } | ||
| 141 | + ] | ||
| 142 | + } | ||
| 143 | + ], | ||
| 144 | + "lastUpdatedDate": "2026-01-15T09:00:00.000Z", | ||
| 145 | + "unknownFutureField": { "accepted": true } | ||
| 146 | +} | ||
modified
package-lock.json +4 -4 | @@ -14,8 +14,8 @@ | ||
| 14 | 14 | "node": "24.18.0" |
| 15 | 15 | } |
| 16 | 16 | }, |
| 17 | - "node_modules/@lapin-fute/backend": { | |
| 18 | - "resolved": "packages/backend", | |
| 17 | + "node_modules/@lapin-fute/catalog": { | |
| 18 | + "resolved": "packages/catalog", | |
| 19 | 19 | "link": true |
| 20 | 20 | }, |
| 21 | 21 | "node_modules/@lapin-fute/companion": { |
| @@ -54,8 +54,8 @@ | ||
| 54 | 54 | "node": ">=12" |
| 55 | 55 | } |
| 56 | 56 | }, |
| 57 | - "packages/backend": { | |
| 58 | - "name": "@lapin-fute/backend", | |
| 57 | + "packages/catalog": { | |
| 58 | + "name": "@lapin-fute/catalog", | |
| 59 | 59 | "version": "0.1.0", |
| 60 | 60 | "dependencies": { |
| 61 | 61 | "csv-parse": "7.0.2", |
| @@ -14,8 +14,8 @@ | |||
| 14 | "node": "24.18.0" | 14 | "node": "24.18.0" |
| 15 | } | 15 | } |
| 16 | }, | 16 | }, |
| 17 | - "node_modules/@lapin-fute/backend": { | 17 | + "node_modules/@lapin-fute/catalog": { |
| 18 | - "resolved": "packages/backend", | 18 | + "resolved": "packages/catalog", |
| 19 | "link": true | 19 | "link": true |
| 20 | }, | 20 | }, |
| 21 | "node_modules/@lapin-fute/companion": { | 21 | "node_modules/@lapin-fute/companion": { |
| @@ -54,8 +54,8 @@ | |||
| 54 | "node": ">=12" | 54 | "node": ">=12" |
| 55 | } | 55 | } |
| 56 | }, | 56 | }, |
| 57 | - "packages/backend": { | 57 | + "packages/catalog": { |
| 58 | - "name": "@lapin-fute/backend", | 58 | + "name": "@lapin-fute/catalog", |
| 59 | "version": "0.1.0", | 59 | "version": "0.1.0", |
| 60 | "dependencies": { | 60 | "dependencies": { |
| 61 | "csv-parse": "7.0.2", | 61 | "csv-parse": "7.0.2", |
modified
package.json +2 -1 | @@ -11,6 +11,7 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "scripts": { |
| 13 | 13 | "build": "node scripts/build.mjs", |
| 14 | + "build:config-site": "node scripts/build-config-site.mjs", | |
| 14 | 15 | "catalog:probe": "node scripts/probe-catalog.mjs", |
| 15 | 16 | "catalog:refresh": "node scripts/refresh-catalog.mjs", |
| 16 | 17 | "badges:sync": "node scripts/sync-line-badges.mjs", |
| @@ -19,7 +20,7 @@ | ||
| 19 | 20 | "measure:config-page": "node scripts/measure-config-page.mjs", |
| 20 | 21 | "test": "node scripts/test.mjs", |
| 21 | 22 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 22 | - "test:watch": "node --test packages/watch/test/*.test.js", | |
| 23 | + "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js", | |
| 23 | 24 | "verify:watch-build": "node scripts/verify-watch-build.mjs" |
| 24 | 25 | } |
| 25 | 26 | } |
| @@ -11,6 +11,7 @@ | |||
| 11 | }, | 11 | }, |
| 12 | "scripts": { | 12 | "scripts": { |
| 13 | "build": "node scripts/build.mjs", | 13 | "build": "node scripts/build.mjs", |
| 14 | + "build:config-site": "node scripts/build-config-site.mjs", | ||
| 14 | "catalog:probe": "node scripts/probe-catalog.mjs", | 15 | "catalog:probe": "node scripts/probe-catalog.mjs", |
| 15 | "catalog:refresh": "node scripts/refresh-catalog.mjs", | 16 | "catalog:refresh": "node scripts/refresh-catalog.mjs", |
| 16 | "badges:sync": "node scripts/sync-line-badges.mjs", | 17 | "badges:sync": "node scripts/sync-line-badges.mjs", |
| @@ -19,7 +20,7 @@ | |||
| 19 | "measure:config-page": "node scripts/measure-config-page.mjs", | 20 | "measure:config-page": "node scripts/measure-config-page.mjs", |
| 20 | "test": "node scripts/test.mjs", | 21 | "test": "node scripts/test.mjs", |
| 21 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", | 22 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 22 | - "test:watch": "node --test packages/watch/test/*.test.js", | 23 | + "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js", |
| 23 | "verify:watch-build": "node scripts/verify-watch-build.mjs" | 24 | "verify:watch-build": "node scripts/verify-watch-build.mjs" |
| 24 | } | 25 | } |
| 25 | } | 26 | } |
deleted
packages/backend/src/departure-endpoint.ts +0 -501 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/backend/src/departure-service.ts +0 -227 | deleted file mode 100644 | ||
| @@ -1,227 +0,0 @@ | ||
| 1 | -import { | |
| 2 | - SCHEMA_VERSION, | |
| 3 | - type ErrorCode, | |
| 4 | -} from "../../contracts/src/index.ts"; | |
| 5 | - | |
| 6 | -import type { CatalogReader } from "./catalog.ts"; | |
| 7 | -import { | |
| 8 | - DEPARTURE_METRIC_NAME, | |
| 9 | - type DepartureMetricName, | |
| 10 | - type DepartureMetrics, | |
| 11 | - type DepartureResolver, | |
| 12 | - type PublicDepartureData, | |
| 13 | - type PublicDepartureError, | |
| 14 | -} from "./departures.ts"; | |
| 15 | -import { | |
| 16 | - buildPrimStopMonitoringUrl, | |
| 17 | - normalizePrimDepartureResponse, | |
| 18 | - parseRetryAfterSeconds, | |
| 19 | -} from "./prim-departures.ts"; | |
| 20 | - | |
| 21 | -export interface PrimDepartureRelayRequest { | |
| 22 | - readonly target: URL; | |
| 23 | - readonly apiKey: string; | |
| 24 | -} | |
| 25 | - | |
| 26 | -export interface PrimDepartureRelayResponse { | |
| 27 | - readonly status: number; | |
| 28 | - readonly bytes: Uint8Array; | |
| 29 | - readonly retryAfter?: string; | |
| 30 | -} | |
| 31 | - | |
| 32 | -export type PrimDepartureRelay = ( | |
| 33 | - request: PrimDepartureRelayRequest, | |
| 34 | -) => Promise<PrimDepartureRelayResponse>; | |
| 35 | - | |
| 36 | -export interface UpstreamLimiter { | |
| 37 | - run<T>(operation: () => Promise<T>): Promise<T>; | |
| 38 | -} | |
| 39 | - | |
| 40 | -export interface PrimDepartureResolverDependencies { | |
| 41 | - readonly catalog: CatalogReader; | |
| 42 | - readonly relay: PrimDepartureRelay; | |
| 43 | - readonly clock?: () => number; | |
| 44 | - readonly metrics?: DepartureMetrics; | |
| 45 | - readonly limiter?: UpstreamLimiter; | |
| 46 | -} | |
| 47 | - | |
| 48 | -export const MAX_UPSTREAM_CALLS = 8 as const; | |
| 49 | -const UINT32_MAX = 0xffff_ffff; | |
| 50 | -const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); | |
| 51 | - | |
| 52 | -export function createUpstreamLimiter( | |
| 53 | - maxActive = MAX_UPSTREAM_CALLS, | |
| 54 | -): UpstreamLimiter { | |
| 55 | - if (!Number.isInteger(maxActive) || maxActive < 1) { | |
| 56 | - throw new RangeError("maxActive must be a positive integer"); | |
| 57 | - } | |
| 58 | - | |
| 59 | - let active = 0; | |
| 60 | - const queue: Array<() => void> = []; | |
| 61 | - | |
| 62 | - async function acquire(): Promise<void> { | |
| 63 | - if (active < maxActive) { | |
| 64 | - active += 1; | |
| 65 | - return; | |
| 66 | - } | |
| 67 | - | |
| 68 | - await new Promise<void>((resolve) => { | |
| 69 | - queue.push(() => { | |
| 70 | - active += 1; | |
| 71 | - resolve(); | |
| 72 | - }); | |
| 73 | - }); | |
| 74 | - } | |
| 75 | - | |
| 76 | - function release(): void { | |
| 77 | - active -= 1; | |
| 78 | - queue.shift()?.(); | |
| 79 | - } | |
| 80 | - | |
| 81 | - return Object.freeze({ | |
| 82 | - async run<T>(operation: () => Promise<T>): Promise<T> { | |
| 83 | - await acquire(); | |
| 84 | - try { | |
| 85 | - return await operation(); | |
| 86 | - } finally { | |
| 87 | - release(); | |
| 88 | - } | |
| 89 | - }, | |
| 90 | - }); | |
| 91 | -} | |
| 92 | - | |
| 93 | -const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter(); | |
| 94 | - | |
| 95 | -function nowMilliseconds(clock: (() => number) | undefined): number { | |
| 96 | - try { | |
| 97 | - const value = clock?.() ?? Date.now(); | |
| 98 | - return Number.isFinite(value) && value >= 0 ? value : Date.now(); | |
| 99 | - } catch { | |
| 100 | - return Date.now(); | |
| 101 | - } | |
| 102 | -} | |
| 103 | - | |
| 104 | -function metric( | |
| 105 | - metrics: DepartureMetrics | undefined, | |
| 106 | - name: DepartureMetricName, | |
| 107 | - value: number, | |
| 108 | -): void { | |
| 109 | - if (!Number.isFinite(value) || value < 0) return; | |
| 110 | - try { | |
| 111 | - metrics?.record(name, value); | |
| 112 | - } catch { | |
| 113 | - // Instrumentation must never affect the relay or request settlement. | |
| 114 | - } | |
| 115 | -} | |
| 116 | - | |
| 117 | -function epochSeconds(milliseconds: number): number { | |
| 118 | - return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | |
| 119 | -} | |
| 120 | - | |
| 121 | -function publicError( | |
| 122 | - code: ErrorCode, | |
| 123 | - milliseconds: number, | |
| 124 | - retryAfterSeconds?: number, | |
| 125 | -): PublicDepartureError { | |
| 126 | - return { | |
| 127 | - schemaVersion: SCHEMA_VERSION, | |
| 128 | - code, | |
| 129 | - occurredAt: epochSeconds(milliseconds), | |
| 130 | - ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | |
| 131 | - }; | |
| 132 | -} | |
| 133 | - | |
| 134 | -interface RelayResponseEnvelope { | |
| 135 | - readonly status: number; | |
| 136 | - readonly bytes?: unknown; | |
| 137 | - readonly retryAfter?: unknown; | |
| 138 | -} | |
| 139 | - | |
| 140 | -function validRelayResponse(response: unknown): response is RelayResponseEnvelope { | |
| 141 | - if (typeof response !== "object" || response === null || !("status" in response)) { | |
| 142 | - return false; | |
| 143 | - } | |
| 144 | - const status = response.status; | |
| 145 | - return typeof status === "number" | |
| 146 | - && Number.isInteger(status) | |
| 147 | - && status >= 100 | |
| 148 | - && status <= 599; | |
| 149 | -} | |
| 150 | - | |
| 151 | -export function createPrimDepartureResolver( | |
| 152 | - dependencies: PrimDepartureResolverDependencies, | |
| 153 | -): DepartureResolver { | |
| 154 | - const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER; | |
| 155 | - | |
| 156 | - return async (request) => { | |
| 157 | - const resolution = dependencies.catalog.resolveService(request.serviceId); | |
| 158 | - if (resolution.status !== "RESOLVED") { | |
| 159 | - return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock)); | |
| 160 | - } | |
| 161 | - | |
| 162 | - const target = buildPrimStopMonitoringUrl(resolution); | |
| 163 | - let apiKey = request.apiKey; | |
| 164 | - let response: unknown; | |
| 165 | - let completedAtMilliseconds: number | undefined; | |
| 166 | - try { | |
| 167 | - response = await limiter.run(async () => { | |
| 168 | - metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1); | |
| 169 | - const startedAtMilliseconds = nowMilliseconds(dependencies.clock); | |
| 170 | - let status = 0; | |
| 171 | - try { | |
| 172 | - const relayResponse = await dependencies.relay({ target, apiKey }); | |
| 173 | - status = validRelayResponse(relayResponse) ? relayResponse.status : 0; | |
| 174 | - return relayResponse; | |
| 175 | - } finally { | |
| 176 | - completedAtMilliseconds = nowMilliseconds(dependencies.clock); | |
| 177 | - metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status); | |
| 178 | - metric( | |
| 179 | - dependencies.metrics, | |
| 180 | - DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, | |
| 181 | - Math.max(0, completedAtMilliseconds - startedAtMilliseconds), | |
| 182 | - ); | |
| 183 | - } | |
| 184 | - }); | |
| 185 | - } catch { | |
| 186 | - return publicError( | |
| 187 | - "SOURCE_UNAVAILABLE", | |
| 188 | - completedAtMilliseconds ?? nowMilliseconds(dependencies.clock), | |
| 189 | - ); | |
| 190 | - } finally { | |
| 191 | - apiKey = ""; | |
| 192 | - } | |
| 193 | - | |
| 194 | - const receivedAtMilliseconds = | |
| 195 | - completedAtMilliseconds ?? nowMilliseconds(dependencies.clock); | |
| 196 | - if (!validRelayResponse(response)) { | |
| 197 | - return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 198 | - } | |
| 199 | - if (response.status === 401 || response.status === 403) { | |
| 200 | - return publicError("API_KEY_INVALID", receivedAtMilliseconds); | |
| 201 | - } | |
| 202 | - if (response.status === 429) { | |
| 203 | - return publicError( | |
| 204 | - "RATE_LIMITED", | |
| 205 | - receivedAtMilliseconds, | |
| 206 | - parseRetryAfterSeconds(response.retryAfter), | |
| 207 | - ); | |
| 208 | - } | |
| 209 | - if (response.status < 200 || response.status >= 300) { | |
| 210 | - return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds); | |
| 211 | - } | |
| 212 | - if (!(response.bytes instanceof Uint8Array)) { | |
| 213 | - return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 214 | - } | |
| 215 | - | |
| 216 | - try { | |
| 217 | - const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown; | |
| 218 | - return normalizePrimDepartureResponse( | |
| 219 | - payload, | |
| 220 | - resolution, | |
| 221 | - epochSeconds(receivedAtMilliseconds), | |
| 222 | - ) as PublicDepartureData; | |
| 223 | - } catch { | |
| 224 | - return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | |
| 225 | - } | |
| 226 | - }; | |
| 227 | -} | |
| deleted file mode 100644 | |||
| @@ -1,227 +0,0 @@ | |||
| 1 | -import { | ||
| 2 | - SCHEMA_VERSION, | ||
| 3 | - type ErrorCode, | ||
| 4 | -} from "../../contracts/src/index.ts"; | ||
| 5 | - | ||
| 6 | -import type { CatalogReader } from "./catalog.ts"; | ||
| 7 | -import { | ||
| 8 | - DEPARTURE_METRIC_NAME, | ||
| 9 | - type DepartureMetricName, | ||
| 10 | - type DepartureMetrics, | ||
| 11 | - type DepartureResolver, | ||
| 12 | - type PublicDepartureData, | ||
| 13 | - type PublicDepartureError, | ||
| 14 | -} from "./departures.ts"; | ||
| 15 | -import { | ||
| 16 | - buildPrimStopMonitoringUrl, | ||
| 17 | - normalizePrimDepartureResponse, | ||
| 18 | - parseRetryAfterSeconds, | ||
| 19 | -} from "./prim-departures.ts"; | ||
| 20 | - | ||
| 21 | -export interface PrimDepartureRelayRequest { | ||
| 22 | - readonly target: URL; | ||
| 23 | - readonly apiKey: string; | ||
| 24 | -} | ||
| 25 | - | ||
| 26 | -export interface PrimDepartureRelayResponse { | ||
| 27 | - readonly status: number; | ||
| 28 | - readonly bytes: Uint8Array; | ||
| 29 | - readonly retryAfter?: string; | ||
| 30 | -} | ||
| 31 | - | ||
| 32 | -export type PrimDepartureRelay = ( | ||
| 33 | - request: PrimDepartureRelayRequest, | ||
| 34 | -) => Promise<PrimDepartureRelayResponse>; | ||
| 35 | - | ||
| 36 | -export interface UpstreamLimiter { | ||
| 37 | - run<T>(operation: () => Promise<T>): Promise<T>; | ||
| 38 | -} | ||
| 39 | - | ||
| 40 | -export interface PrimDepartureResolverDependencies { | ||
| 41 | - readonly catalog: CatalogReader; | ||
| 42 | - readonly relay: PrimDepartureRelay; | ||
| 43 | - readonly clock?: () => number; | ||
| 44 | - readonly metrics?: DepartureMetrics; | ||
| 45 | - readonly limiter?: UpstreamLimiter; | ||
| 46 | -} | ||
| 47 | - | ||
| 48 | -export const MAX_UPSTREAM_CALLS = 8 as const; | ||
| 49 | -const UINT32_MAX = 0xffff_ffff; | ||
| 50 | -const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); | ||
| 51 | - | ||
| 52 | -export function createUpstreamLimiter( | ||
| 53 | - maxActive = MAX_UPSTREAM_CALLS, | ||
| 54 | -): UpstreamLimiter { | ||
| 55 | - if (!Number.isInteger(maxActive) || maxActive < 1) { | ||
| 56 | - throw new RangeError("maxActive must be a positive integer"); | ||
| 57 | - } | ||
| 58 | - | ||
| 59 | - let active = 0; | ||
| 60 | - const queue: Array<() => void> = []; | ||
| 61 | - | ||
| 62 | - async function acquire(): Promise<void> { | ||
| 63 | - if (active < maxActive) { | ||
| 64 | - active += 1; | ||
| 65 | - return; | ||
| 66 | - } | ||
| 67 | - | ||
| 68 | - await new Promise<void>((resolve) => { | ||
| 69 | - queue.push(() => { | ||
| 70 | - active += 1; | ||
| 71 | - resolve(); | ||
| 72 | - }); | ||
| 73 | - }); | ||
| 74 | - } | ||
| 75 | - | ||
| 76 | - function release(): void { | ||
| 77 | - active -= 1; | ||
| 78 | - queue.shift()?.(); | ||
| 79 | - } | ||
| 80 | - | ||
| 81 | - return Object.freeze({ | ||
| 82 | - async run<T>(operation: () => Promise<T>): Promise<T> { | ||
| 83 | - await acquire(); | ||
| 84 | - try { | ||
| 85 | - return await operation(); | ||
| 86 | - } finally { | ||
| 87 | - release(); | ||
| 88 | - } | ||
| 89 | - }, | ||
| 90 | - }); | ||
| 91 | -} | ||
| 92 | - | ||
| 93 | -const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter(); | ||
| 94 | - | ||
| 95 | -function nowMilliseconds(clock: (() => number) | undefined): number { | ||
| 96 | - try { | ||
| 97 | - const value = clock?.() ?? Date.now(); | ||
| 98 | - return Number.isFinite(value) && value >= 0 ? value : Date.now(); | ||
| 99 | - } catch { | ||
| 100 | - return Date.now(); | ||
| 101 | - } | ||
| 102 | -} | ||
| 103 | - | ||
| 104 | -function metric( | ||
| 105 | - metrics: DepartureMetrics | undefined, | ||
| 106 | - name: DepartureMetricName, | ||
| 107 | - value: number, | ||
| 108 | -): void { | ||
| 109 | - if (!Number.isFinite(value) || value < 0) return; | ||
| 110 | - try { | ||
| 111 | - metrics?.record(name, value); | ||
| 112 | - } catch { | ||
| 113 | - // Instrumentation must never affect the relay or request settlement. | ||
| 114 | - } | ||
| 115 | -} | ||
| 116 | - | ||
| 117 | -function epochSeconds(milliseconds: number): number { | ||
| 118 | - return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000)); | ||
| 119 | -} | ||
| 120 | - | ||
| 121 | -function publicError( | ||
| 122 | - code: ErrorCode, | ||
| 123 | - milliseconds: number, | ||
| 124 | - retryAfterSeconds?: number, | ||
| 125 | -): PublicDepartureError { | ||
| 126 | - return { | ||
| 127 | - schemaVersion: SCHEMA_VERSION, | ||
| 128 | - code, | ||
| 129 | - occurredAt: epochSeconds(milliseconds), | ||
| 130 | - ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), | ||
| 131 | - }; | ||
| 132 | -} | ||
| 133 | - | ||
| 134 | -interface RelayResponseEnvelope { | ||
| 135 | - readonly status: number; | ||
| 136 | - readonly bytes?: unknown; | ||
| 137 | - readonly retryAfter?: unknown; | ||
| 138 | -} | ||
| 139 | - | ||
| 140 | -function validRelayResponse(response: unknown): response is RelayResponseEnvelope { | ||
| 141 | - if (typeof response !== "object" || response === null || !("status" in response)) { | ||
| 142 | - return false; | ||
| 143 | - } | ||
| 144 | - const status = response.status; | ||
| 145 | - return typeof status === "number" | ||
| 146 | - && Number.isInteger(status) | ||
| 147 | - && status >= 100 | ||
| 148 | - && status <= 599; | ||
| 149 | -} | ||
| 150 | - | ||
| 151 | -export function createPrimDepartureResolver( | ||
| 152 | - dependencies: PrimDepartureResolverDependencies, | ||
| 153 | -): DepartureResolver { | ||
| 154 | - const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER; | ||
| 155 | - | ||
| 156 | - return async (request) => { | ||
| 157 | - const resolution = dependencies.catalog.resolveService(request.serviceId); | ||
| 158 | - if (resolution.status !== "RESOLVED") { | ||
| 159 | - return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock)); | ||
| 160 | - } | ||
| 161 | - | ||
| 162 | - const target = buildPrimStopMonitoringUrl(resolution); | ||
| 163 | - let apiKey = request.apiKey; | ||
| 164 | - let response: unknown; | ||
| 165 | - let completedAtMilliseconds: number | undefined; | ||
| 166 | - try { | ||
| 167 | - response = await limiter.run(async () => { | ||
| 168 | - metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1); | ||
| 169 | - const startedAtMilliseconds = nowMilliseconds(dependencies.clock); | ||
| 170 | - let status = 0; | ||
| 171 | - try { | ||
| 172 | - const relayResponse = await dependencies.relay({ target, apiKey }); | ||
| 173 | - status = validRelayResponse(relayResponse) ? relayResponse.status : 0; | ||
| 174 | - return relayResponse; | ||
| 175 | - } finally { | ||
| 176 | - completedAtMilliseconds = nowMilliseconds(dependencies.clock); | ||
| 177 | - metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status); | ||
| 178 | - metric( | ||
| 179 | - dependencies.metrics, | ||
| 180 | - DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS, | ||
| 181 | - Math.max(0, completedAtMilliseconds - startedAtMilliseconds), | ||
| 182 | - ); | ||
| 183 | - } | ||
| 184 | - }); | ||
| 185 | - } catch { | ||
| 186 | - return publicError( | ||
| 187 | - "SOURCE_UNAVAILABLE", | ||
| 188 | - completedAtMilliseconds ?? nowMilliseconds(dependencies.clock), | ||
| 189 | - ); | ||
| 190 | - } finally { | ||
| 191 | - apiKey = ""; | ||
| 192 | - } | ||
| 193 | - | ||
| 194 | - const receivedAtMilliseconds = | ||
| 195 | - completedAtMilliseconds ?? nowMilliseconds(dependencies.clock); | ||
| 196 | - if (!validRelayResponse(response)) { | ||
| 197 | - return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 198 | - } | ||
| 199 | - if (response.status === 401 || response.status === 403) { | ||
| 200 | - return publicError("API_KEY_INVALID", receivedAtMilliseconds); | ||
| 201 | - } | ||
| 202 | - if (response.status === 429) { | ||
| 203 | - return publicError( | ||
| 204 | - "RATE_LIMITED", | ||
| 205 | - receivedAtMilliseconds, | ||
| 206 | - parseRetryAfterSeconds(response.retryAfter), | ||
| 207 | - ); | ||
| 208 | - } | ||
| 209 | - if (response.status < 200 || response.status >= 300) { | ||
| 210 | - return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds); | ||
| 211 | - } | ||
| 212 | - if (!(response.bytes instanceof Uint8Array)) { | ||
| 213 | - return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 214 | - } | ||
| 215 | - | ||
| 216 | - try { | ||
| 217 | - const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown; | ||
| 218 | - return normalizePrimDepartureResponse( | ||
| 219 | - payload, | ||
| 220 | - resolution, | ||
| 221 | - epochSeconds(receivedAtMilliseconds), | ||
| 222 | - ) as PublicDepartureData; | ||
| 223 | - } catch { | ||
| 224 | - return publicError("INVALID_RESPONSE", receivedAtMilliseconds); | ||
| 225 | - } | ||
| 226 | - }; | ||
| 227 | -} | ||
deleted
packages/backend/src/departures.ts +0 -695 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/backend/src/prim-departures.ts +0 -258 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/backend/test/backend.test.ts +0 -524 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/backend/test/catalog-http.test.ts +0 -158 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/backend/test/departure-endpoint.test.ts +0 -810 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/backend/test/departures.test.ts +0 -1442 Large diff collapsed to keep the page fast — load it when you need it.
renamed
packages/catalog/package.json +1 -2 | similarity index 75% | ||
| rename from packages/backend/package.json | ||
| rename to packages/catalog/package.json | ||
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | { |
| 2 | - "name": "@lapin-fute/backend", | |
| 2 | + "name": "@lapin-fute/catalog", | |
| 3 | 3 | "version": "0.1.0", |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | - "exports": "./src/index.ts", | |
| 7 | 6 | "dependencies": { |
| 8 | 7 | "csv-parse": "7.0.2", |
| 9 | 8 | "yauzl": "3.4.0" |
| similarity index 75% | |||
| rename from packages/backend/package.json | |||
| rename to packages/catalog/package.json | |||
| @@ -1,9 +1,8 @@ | |||
| 1 | { | 1 | { |
| 2 | - "name": "@lapin-fute/backend", | 2 | + "name": "@lapin-fute/catalog", |
| 3 | "version": "0.1.0", | 3 | "version": "0.1.0", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | - "exports": "./src/index.ts", | ||
| 7 | "dependencies": { | 6 | "dependencies": { |
| 8 | "csv-parse": "7.0.2", | 7 | "csv-parse": "7.0.2", |
| 9 | "yauzl": "3.4.0" | 8 | "yauzl": "3.4.0" |
renamed
packages/catalog/src/catalog-import.ts +175 -100 Large diff collapsed to keep the page fast — load it when you need it.
renamed
packages/catalog/src/catalog.ts +21 -106 Large diff collapsed to keep the page fast — load it when you need it.
renamed
packages/catalog/src/prim-probe.ts +47 -62 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/catalog/src/static-catalog.ts +343 -0 Large diff collapsed to keep the page fast — load it when you need it.
renamed
packages/catalog/test/catalog-import.test.ts +220 -2 Large diff collapsed to keep the page fast — load it when you need it.
renamed
packages/catalog/test/catalog.test.ts +6 -13 | similarity index 97% | ||
| rename from packages/backend/test/catalog.test.ts | ||
| rename to packages/catalog/test/catalog.test.ts | ||
| @@ -223,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | ||
| 223 | 223 | |
| 224 | 224 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); |
| 225 | 225 | metadata.run("schema_version", "1"); |
| 226 | - metadata.run("catalog_version", seed.catalogVersion ?? "2"); | |
| 226 | + metadata.run("catalog_version", seed.catalogVersion ?? "3"); | |
| 227 | 227 | metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); |
| 228 | 228 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); |
| 229 | 229 | |
| @@ -419,7 +419,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 419 | 419 | status: "RESOLVED", |
| 420 | 420 | monitoringRef: "raw-shared-rer-station", |
| 421 | 421 | lineRef: "raw-line-rer-b", |
| 422 | - directionId: "north", | |
| 423 | 422 | destinationRef: "terminal-rer-north", |
| 424 | 423 | destinationLabel: "Aéroport Charles de Gaulle 2", |
| 425 | 424 | }); |
| @@ -427,7 +426,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 427 | 426 | status: "RESOLVED", |
| 428 | 427 | monitoringRef: "raw-shared-rer-station", |
| 429 | 428 | lineRef: "raw-line-rer-b", |
| 430 | - directionId: "south", | |
| 431 | 429 | destinationRef: "terminal-rer-south", |
| 432 | 430 | destinationLabel: "Saint-Rémy-lès-Chevreuse", |
| 433 | 431 | }); |
| @@ -452,7 +450,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 452 | 450 | status: "RESOLVED", |
| 453 | 451 | monitoringRef: "raw-shared-rer-station", |
| 454 | 452 | lineRef: "raw-line-rer-b", |
| 455 | - directionId: "north", | |
| 456 | 453 | destinationRef: "terminal-rer-north", |
| 457 | 454 | destinationLabel: "Aéroport CDG 2", |
| 458 | 455 | }); |
| @@ -461,15 +458,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 461 | 458 | { status: "UNRESOLVED", code: "INVALID_SERVICE" }, |
| 462 | 459 | ); |
| 463 | 460 | assert.equal(manager.listServices("plc_missing"), undefined); |
| 464 | - assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ | |
| 465 | - "destinationLabel", | |
| 466 | - "lineColor", | |
| 467 | - "lineLabel", | |
| 468 | - "lineMode", | |
| 469 | - "lineTextColor", | |
| 470 | - "serviceId", | |
| 471 | - "stopLabel", | |
| 472 | - ]); | |
| 473 | 461 | assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], { |
| 474 | 462 | serviceId: replacement.serviceIds["metro-south"], |
| 475 | 463 | stopLabel: "Châtelet", |
| @@ -478,6 +466,11 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | ||
| 478 | 466 | lineMode: "METRO", |
| 479 | 467 | lineColor: "#123456", |
| 480 | 468 | lineTextColor: "#ffffff", |
| 469 | + routing: { | |
| 470 | + monitoringRef: "raw-monitoring-metro-south", | |
| 471 | + lineRef: "raw-line-metro-4", | |
| 472 | + destinationRef: "terminal-metro-south", | |
| 473 | + }, | |
| 481 | 474 | }); |
| 482 | 475 | }); |
| 483 | 476 | |
| similarity index 97% | |||
| rename from packages/backend/test/catalog.test.ts | |||
| rename to packages/catalog/test/catalog.test.ts | |||
| @@ -223,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog { | |||
| 223 | 223 | ||
| 224 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); | 224 | const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)"); |
| 225 | metadata.run("schema_version", "1"); | 225 | metadata.run("schema_version", "1"); |
| 226 | - metadata.run("catalog_version", seed.catalogVersion ?? "2"); | 226 | + metadata.run("catalog_version", seed.catalogVersion ?? "3"); |
| 227 | metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); | 227 | metadata.run("source_revision", seed.revision ?? "fixture-revision-a"); |
| 228 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); | 228 | metadata.run("created_at", "2026-08-30T12:00:00.000Z"); |
| 229 | 229 | ||
| @@ -419,7 +419,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 419 | status: "RESOLVED", | 419 | status: "RESOLVED", |
| 420 | monitoringRef: "raw-shared-rer-station", | 420 | monitoringRef: "raw-shared-rer-station", |
| 421 | lineRef: "raw-line-rer-b", | 421 | lineRef: "raw-line-rer-b", |
| 422 | - directionId: "north", | ||
| 423 | destinationRef: "terminal-rer-north", | 422 | destinationRef: "terminal-rer-north", |
| 424 | destinationLabel: "Aéroport Charles de Gaulle 2", | 423 | destinationLabel: "Aéroport Charles de Gaulle 2", |
| 425 | }); | 424 | }); |
| @@ -427,7 +426,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 427 | status: "RESOLVED", | 426 | status: "RESOLVED", |
| 428 | monitoringRef: "raw-shared-rer-station", | 427 | monitoringRef: "raw-shared-rer-station", |
| 429 | lineRef: "raw-line-rer-b", | 428 | lineRef: "raw-line-rer-b", |
| 430 | - directionId: "south", | ||
| 431 | destinationRef: "terminal-rer-south", | 429 | destinationRef: "terminal-rer-south", |
| 432 | destinationLabel: "Saint-Rémy-lès-Chevreuse", | 430 | destinationLabel: "Saint-Rémy-lès-Chevreuse", |
| 433 | }); | 431 | }); |
| @@ -452,7 +450,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 452 | status: "RESOLVED", | 450 | status: "RESOLVED", |
| 453 | monitoringRef: "raw-shared-rer-station", | 451 | monitoringRef: "raw-shared-rer-station", |
| 454 | lineRef: "raw-line-rer-b", | 452 | lineRef: "raw-line-rer-b", |
| 455 | - directionId: "north", | ||
| 456 | destinationRef: "terminal-rer-north", | 453 | destinationRef: "terminal-rer-north", |
| 457 | destinationLabel: "Aéroport CDG 2", | 454 | destinationLabel: "Aéroport CDG 2", |
| 458 | }); | 455 | }); |
| @@ -461,15 +458,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 461 | { status: "UNRESOLVED", code: "INVALID_SERVICE" }, | 458 | { status: "UNRESOLVED", code: "INVALID_SERVICE" }, |
| 462 | ); | 459 | ); |
| 463 | assert.equal(manager.listServices("plc_missing"), undefined); | 460 | assert.equal(manager.listServices("plc_missing"), undefined); |
| 464 | - assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [ | ||
| 465 | - "destinationLabel", | ||
| 466 | - "lineColor", | ||
| 467 | - "lineLabel", | ||
| 468 | - "lineMode", | ||
| 469 | - "lineTextColor", | ||
| 470 | - "serviceId", | ||
| 471 | - "stopLabel", | ||
| 472 | - ]); | ||
| 473 | assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], { | 461 | assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], { |
| 474 | serviceId: replacement.serviceIds["metro-south"], | 462 | serviceId: replacement.serviceIds["metro-south"], |
| 475 | stopLabel: "Châtelet", | 463 | stopLabel: "Châtelet", |
| @@ -478,6 +466,11 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can | |||
| 478 | lineMode: "METRO", | 466 | lineMode: "METRO", |
| 479 | lineColor: "#123456", | 467 | lineColor: "#123456", |
| 480 | lineTextColor: "#ffffff", | 468 | lineTextColor: "#ffffff", |
| 469 | + routing: { | ||
| 470 | + monitoringRef: "raw-monitoring-metro-south", | ||
| 471 | + lineRef: "raw-line-metro-4", | ||
| 472 | + destinationRef: "terminal-metro-south", | ||
| 473 | + }, | ||
| 481 | }); | 474 | }); |
| 482 | }); | 475 | }); |
| 483 | 476 | ||
added
packages/catalog/test/static-catalog.test.js +216 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/catalog-client.js +155 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/src/codec.js +133 -129 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/compactparis-time.js +78 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/configuration-sync.js +205 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/src/configuration.js +379 -135 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/src/contracts.js +467 -66 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/display-copy.json +237 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/display-font-metrics.json +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +{"sdk":"4.33.1","platforms":["emery","gabbro"],"roles":["14px Gothic","bold 14px Gothic","bold 18px Gothic","bold 36px Gothic"],"resourceKeys":["FONT_KEY_GOTHIC_14","FONT_KEY_GOTHIC_14_BOLD","FONT_KEY_GOTHIC_18_BOLD","FONT_KEY_GOTHIC_36_BOLD"],"heights":[14,14,18,36],"capOffsets":[3,3,3,7],"ascent":[10,10,14,28],"fallback":[7,7,9,18],"groups":[[[0,0,0,0],"🏻🏼🏽🏾🏿"],[[2,4,3,8],"¸"],[[3,3,3,6],"jï"],[[3,3,4,6],"ĵ"],[[3,3,4,8]," , Ï‘’‚∫"],[[3,3,4,10],"Į"],[[3,3,5,10],"ł"],[[3,4,3,6],"ĩīĭļ"],[[3,4,3,7],"į"],[[3,4,4,6],"î"],[[3,4,4,7],"ilìíıĺ"],[[3,4,4,8],"!'.:;I|¡¦·ÍĨİ˙"],[[3,4,4,9],"Ĭ"],[[3,4,5,8],"Ì"],[[3,4,5,9],"Ī"],[[3,4,6,8],"Î"],[[3,6,8,16],"ˆ"],[[4,5,5,10],"[]"],[[4,5,6,12],"{"],[[4,5,7,14],"˛"],[[4,6,6,11],"ftţŧſƒț"],[[4,6,6,12],"r}ŕŗ"],[[4,6,7,11],"ř"],[[4,6,7,13],"J"],[[4,6,7,14],"¯˘"],[[4,6,8,13],"Ĵ"],[[5,4,6,12],"‹›"],[[5,4,7,14],"−"],[[5,5,6,12],"`´¹"],[[5,5,7,14],"-³"],[[5,6,6,12],"()"],[[5,6,7,13],"zźż"],[[5,6,7,14],"²ž“”„"],[[5,6,8,16],"ˇ˜†‡"],[[5,7,7,13],"ŀ"],[[5,7,7,14],"\""],[[5,7,8,16],"?FL¨¿ĹĻĽĿ˝"],[[5,8,8,16],"TŢŤŦȚ"],[[5,8,9,17],"Ł"],[[6,5,8,16],"<>"],[[6,6,6,12],"˚"],[[6,6,7,9],"🎤"],[[6,6,7,14],"°"],[[6,6,8,16],"0123456789§∂∑"],[[6,7,7,13],"sijľśşș"],[[6,7,7,14],"kķĸŝš•"],[[6,7,7,15],"ãñõğũ"],[[6,7,8,14],"øħ"],[[6,7,8,15],"abcdeghnopquxàáâäåçèéêëòóôöùúûüþāăćĉċčēĕėęěĝġģĥńņňʼnŋōŏőūŭůűǿ"],[[6,7,8,16],"/EZ\\¢ªµºÈÉÊËðąđĒĔĖĘĚųŹŻŽ⁄"],[[6,7,9,18],"£π"],[[6,8,7,16],"¤"],[[6,8,8,17],"KĶ"],[[6,8,9,16],"_"],[[6,8,9,17],"ß"],[[6,9,8,15],"ť"],[[6,9,9,17],"fifl"],[[7,7,7,7],"❗"],[[7,7,8,16],"*^¬"],[[7,7,10,20],"÷∆≠"],[[7,8,7,14],"~"],[[7,8,8,15],"vyýÿŷ"],[[7,8,9,17],"Š"],[[7,8,9,18],"$ABCDGHNOPQRSUVXÀÁÂÃÄÅÇÑÒÓÔÕÖØÙÚÛÜÞĀĂĆĈĊČĎĐĜĞĠĢĤĦŃŅŇŊŌŎŐŔŖŘŚŜŞŨŪŬŮŰŲǾȘ"],[[7,8,9,19],"Ą€"],[[7,8,10,19],"Ð"],[[7,8,10,20],"+=Y¥±ÝŶŸ◊"],[[7,9,9,20],"#"],[[7,9,10,20],"IJ"],[[7,9,11,22],"–"],[[8,7,9,18],"«»"],[[8,7,10,20],"≈"],[[8,8,8,16],"¶"],[[8,8,10,20],"∏"],[[8,9,10,20],"&"],[[9,9,10,10],"►"],[[9,9,10,19],"☎"],[[9,10,11,21],"ď"],[[9,10,11,22],"®"],[[9,10,12,23],"mwæœŵǽ"],[[9,10,12,24],"MÆǼ…√"],[[10,10,11,18],"❤💙💚💛💜"],[[10,10,11,25],"♥"],[[10,10,12,24],"@¼"],[[10,10,12,25],"¾"],[[10,11,12,24],"½"],[[10,11,13,26],"Œ"],[[11,10,11,22],"™"],[[11,10,12,24],"∞"],[[11,11,11,16],"🍺"],[[11,11,11,17],"🖤"],[[11,11,11,18],"💔💗💝"],[[11,12,12,24],"WŴ"],[[11,12,14,28],"%"],[[12,12,12,14],"💡"],[[12,12,12,19],"💘"],[[12,12,13,17],"★📷"],[[12,12,13,18],"💖"],[[12,12,13,19],"😾🙁🙂"],[[12,12,13,25],"✈"],[[12,12,13,26],"©"],[[12,13,15,30],"—"],[[13,13,13,14],"☝"],[[13,13,13,15],"✅❎❣🤘"],[[13,13,13,16],"💟"],[[13,13,13,17],"⚧💀"],[[13,13,13,18],"😀😁😃😄😆😇😉😊😋😌😎😏😐😑😒😔😕😖😗😙😚😛😜😝😞😟😠😡😢😣😤😦😧😨😩😪😫😬😭😮😯😱😲😳😴😵😶😷🙃🤣🤤🤪🥺"],[[13,13,13,19],"☺"],[[13,13,13,20],"🌙🌷🌸🌺👀💣"],[[13,13,14,16],"🎥"],[[13,13,17,25],"⌛⌨⏏⏩⏪⏫⏬⏭⏮⏯⏰⏱⏲⏳⏸⏹⏺▪▫▶◀◻◼◽◾☀☁☂☃☄☑☔☕☘☢☣☦☪☮☯☸☹♀♂♈♉♊♋♌♍♎♏♐♑♒♓♟♠♣♦♨♻♾♿⚒⚓⚔⚕⚖⚗⚙⚛⚜⚠⚡⚪⚫⚰⚱⚽⚾⛄⛅⛈⛎⛏⛑⛓⛔⛩⛪⛰⛱⛲⛳⛴⛵⛷⛸⛹⛺⛽✂✉✍✏✒✔✖✝✡✳✴❄❇❌❓❔❕➕➖➗➡➰➿⬅⬆⬇⬛⬜⭕🅰🅱🅾🅿🆎🆑🆒🆓🆔🆕🆖🆗🆘🆙🆚🈁🈂🈚🈯🈲🈳🈴🈵🈶🈷🈸🈹🈺🉐🉑🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌚🌛🌜🌝🌞🌠🌡🌤🌥🌦🌧🌨🌩🌪🌫🌬🌭🌮🌯🌰🌱🌲🌳🌴🌵🌶🌹🌻🌼🌽🌾🌿🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓🍔🍕🍖🍗🍘🍙🍚🍛🍜🍝🍞🍟🍠🍡🍢🍣🍤🍥🍦🍧🍨🍩🍪🍫🍬🍭🍮🍯🍰🍱🍲🍳🍴🍵🍶🍷🍸🍹🍼🍽🍾🍿🎀🎂🎃🎄🎅🎆🎇🎈🎊🎋🎌🎍🎎🎏🎐🎑🎒🎓🎖🎗🎙🎚🎛🎞🎟🎠🎡🎢🎣🎦🎧🎨🎩🎪🎫🎬🎭🎮🎯🎰🎱🎲🎳🎴🎷🎸🎹🎺🎻🎼🎽🎾🎿🏀🏁🏂🏃🏄🏅🏆🏇🏈🏉🏊🏋🏌🏍🏎🏏🏐🏑🏒🏓🏔🏕🏖🏗🏘🏙🏚🏛🏜🏝🏞🏟🏠🏡🏢🏣🏤🏥🏦🏧🏨🏩🏪🏫🏬🏭🏮🏯🏰🏴🏵🏷🏸🏹🏺🐀🐁🐂🐃🐄🐅🐆🐇🐈🐉🐊🐋🐌🐍🐎🐏🐐🐑🐒🐓🐔🐕🐖🐗🐘🐙🐚🐛🐜🐝🐞🐟🐠🐡🐢🐣🐤🐦🐧🐨🐩🐪🐫🐬🐭🐮🐯🐰🐱🐲🐳🐴🐶🐷🐸🐹🐺🐻🐼🐽🐾🐿👁👂👃👄👅👑👒👓👔👕👖👗👘👙👚👛👜👝👞👟👠👡👢👣👤👥👦👧👨👩👪👫👬👭👮👯👰👱👲👳👴👵👶👷👸👹👺👻👼👽👾💁💂💃💄💅💆💇💈💉💊💌💍💎💏💐💑💒💠💢💤💦💧💨💫💬💭💮💰💱💲💳💴💵💶💷💸💹💺💻💼💽💾💿📀📁📂📃📄📅📆📇📈📉📊📋📌📍📎📏📐📑📒📓📔📕📖📗📘📙📚📛📜📝📞📟📠📡📢📣📤📥📦📧📨📩📪📫📬📭📮📯📰📱📲📳📴📵📶📸📹📺📻📼📽📿🔀🔁🔂🔃🔄🔅🔆🔇🔈🔉🔊🔋🔌🔍🔎🔏🔐🔑🔒🔓🔔🔕🔖🔗🔘🔙🔚🔛🔜🔝🔞🔟🔠🔡🔢🔣🔤🔦🔧🔨🔩🔪🔫🔬🔭🔮🔯🔰🔱🔲🔳🔴🔵🔶🔷🔸🔹🔺🔻🔼🔽🕉🕊🕋🕌🕍🕎🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧🕯🕰🕳🕴🕵🕶🕷🕸🕹🕺🖇🖊🖋🖌🖍🖐🖕🖖🖥🖨🖱🖲🖼🗂🗃🗄🗑🗒🗓🗜🗝🗞🗡🗣🗨🗯🗳🗺🗻🗼🗽🗾🗿🙅🙆🙇🙉🙊🙋🙍🙎🚀🚁🚂🚃🚄🚅🚆🚇🚈🚉🚊🚋🚌🚍🚎🚏🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🚝🚞🚟🚠🚡🚢🚣🚤🚥🚦🚧🚨🚩🚪🚫🚬🚭🚮🚯🚰🚱🚲🚳🚴🚵🚶🚷🚸🚹🚺🚻🚼🚽🚾🚿🛀🛁🛂🛃🛄🛅🛋🛌🛍🛎🛏🛐🛑🛒🛕🛖🛗🛜🛝🛞🛟🛠🛡🛢🛣🛤🛥🛩🛫🛬🛰🛳🛴🛵🛶🛷🛸🛹🛺🛻🛼🟠🟡🟢🟣🟤🟥🟦🟧🟨🟩🟪🟫🟰🤌🤍🤎🤏🤐🤑🤒🤓🤔🤕🤖🤙🤚🤛🤜🤞🤟🤠🤡🤢🤥🤦🤧🤨🤫🤭🤯🤰🤱🤲🤳🤴🤵🤶🤷🤸🤹🤺🤼🤽🤾🤿🥀🥁🥂🥃🥄🥅🥇🥈🥉🥊🥋🥌🥍🥎🥏🥐🥑🥒🥓🥔🥕🥖🥗🥘🥙🥚🥛🥜🥝🥞🥟🥠🥡🥢🥣🥤🥥🥦🥧🥨🥩🥪🥫🥬🥭🥮🥯🥱🥲🥳🥴🥵🥶🥷🥸🥹🥻🥼🥽🥾🥿🦀🦁🦂🦃🦄🦅🦆🦇🦈🦉🦊🦋🦌🦍🦎🦏🦐🦑🦒🦓🦔🦕🦖🦗🦘🦙🦚🦛🦜🦝🦞🦟🦠🦡🦢🦣🦤🦥🦦🦧🦨🦩🦪🦫🦬🦭🦮🦯🦰🦱🦲🦳🦴🦵🦶🦷🦸🦹🦺🦻🦼🦽🦾🦿🧀🧁🧂🧃🧄🧅🧆🧇🧈🧉🧊🧋🧌🧍🧎🧏🧐🧑🧒🧓🧔🧕🧖🧗🧘🧙🧚🧛🧜🧝🧞🧟🧠🧡🧢🧣🧤🧥🧦🧧🧨🧩🧪🧫🧬🧭🧮🧯🧰🧱🧲🧳🧴🧵🧶🧷🧸🧹🧺🧻🧼🧽🧾🧿🩰🩱🩲🩳🩴🩵🩶🩷🩸🩹🩺🩻🩼🪀🪁🪂🪃🪄🪅🪆🪇🪈🪐🪑🪒🪓🪔🪕🪖🪗🪘🪙🪚🪛🪜🪝🪞🪟🪠🪡🪢🪣🪤🪥🪦🪧🪨🪩🪪🪫🪬🪭🪮🪯🪰🪱🪲🪳🪴🪵🪶🪷🪸🪹🪺🪻🪼🪽🪿🫀🫁🫂🫃🫄🫅🫎🫏🫐🫑🫒🫓🫔🫕🫖🫗🫘🫙🫚🫛🫠🫡🫢🫣🫤🫥🫦🫧🫨🫰🫱🫲🫳🫴🫵🫶🫷🫸"],[[14,14,14,15],"✊✋✌"],[[14,14,14,17],"🐥💯"],[[14,14,14,18],"🎵"],[[14,14,14,19],"😸😺😻😼😽🙀"],[[14,14,14,20],"😿"],[[14,14,14,21],"✨"],[[14,14,14,22],"😹"],[[14,14,15,15],"→"],[[14,15,19,38],"‰"],[[15,15,15,13],"👊"],[[15,15,15,16],"👆👇"],[[15,15,15,17],"🍀"],[[15,15,15,18],"🙄"],[[15,15,15,19],"🎁💕💞"],[[15,15,15,22],"😂🤝"],[[15,15,16,20],"🐵🙈"],[[16,16,16,17],"👌"],[[16,16,16,18],"🇦🇧🇨🇩🇪🇫🇬🇭🇮🇯🇰🇱🇲🇳🇴🇵🇶🇷🇸🇹🇺🇻🇼🇽🇾🇿🏳"],[[16,16,16,19],"💩🔥"],[[16,16,16,20],"😅😓😥😰"],[[16,16,16,21],"⭐🌟😘"],[[17,17,17,18],"💪🤮"],[[17,17,17,20],"😈🤗🤬"],[[17,17,17,21],"🎉👿💥"],[[17,17,17,22],"😍🙏🤩"],[[17,17,17,23],"☠"],[[18,18,18,19],"🎶👈👉👋👍👎"],[[19,19,19,19],"⌚"],[[19,19,19,20],"🍻💓"],[[19,19,19,22],"🥰"],[[20,20,20,21],"👏💋"],[[23,23,23,24],"🙌"],[[27,27,27,28],"👐"]],"note":"Exact native single-codepoint widths. Whole string shaping/control/layout rules are separate, not globally additive."} | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +{"sdk":"4.33.1","platforms":["emery","gabbro"],"roles":["14px Gothic","bold 14px Gothic","bold 18px Gothic","bold 36px Gothic"],"resourceKeys":["FONT_KEY_GOTHIC_14","FONT_KEY_GOTHIC_14_BOLD","FONT_KEY_GOTHIC_18_BOLD","FONT_KEY_GOTHIC_36_BOLD"],"heights":[14,14,18,36],"capOffsets":[3,3,3,7],"ascent":[10,10,14,28],"fallback":[7,7,9,18],"groups":[[[0,0,0,0],"🏻🏼🏽🏾🏿"],[[2,4,3,8],"¸"],[[3,3,3,6],"jï"],[[3,3,4,6],"ĵ"],[[3,3,4,8]," , Ï‘’‚∫"],[[3,3,4,10],"Į"],[[3,3,5,10],"ł"],[[3,4,3,6],"ĩīĭļ"],[[3,4,3,7],"į"],[[3,4,4,6],"î"],[[3,4,4,7],"ilìíıĺ"],[[3,4,4,8],"!'.:;I|¡¦·ÍĨİ˙"],[[3,4,4,9],"Ĭ"],[[3,4,5,8],"Ì"],[[3,4,5,9],"Ī"],[[3,4,6,8],"Î"],[[3,6,8,16],"ˆ"],[[4,5,5,10],"[]"],[[4,5,6,12],"{"],[[4,5,7,14],"˛"],[[4,6,6,11],"ftţŧſƒț"],[[4,6,6,12],"r}ŕŗ"],[[4,6,7,11],"ř"],[[4,6,7,13],"J"],[[4,6,7,14],"¯˘"],[[4,6,8,13],"Ĵ"],[[5,4,6,12],"‹›"],[[5,4,7,14],"−"],[[5,5,6,12],"`´¹"],[[5,5,7,14],"-³"],[[5,6,6,12],"()"],[[5,6,7,13],"zźż"],[[5,6,7,14],"²ž“”„"],[[5,6,8,16],"ˇ˜†‡"],[[5,7,7,13],"ŀ"],[[5,7,7,14],"\""],[[5,7,8,16],"?FL¨¿ĹĻĽĿ˝"],[[5,8,8,16],"TŢŤŦȚ"],[[5,8,9,17],"Ł"],[[6,5,8,16],"<>"],[[6,6,6,12],"˚"],[[6,6,7,9],"🎤"],[[6,6,7,14],"°"],[[6,6,8,16],"0123456789§∂∑"],[[6,7,7,13],"sijľśşș"],[[6,7,7,14],"kķĸŝš•"],[[6,7,7,15],"ãñõğũ"],[[6,7,8,14],"øħ"],[[6,7,8,15],"abcdeghnopquxàáâäåçèéêëòóôöùúûüþāăćĉċčēĕėęěĝġģĥńņňʼnŋōŏőūŭůűǿ"],[[6,7,8,16],"/EZ\\¢ªµºÈÉÊËðąđĒĔĖĘĚųŹŻŽ⁄"],[[6,7,9,18],"£π"],[[6,8,7,16],"¤"],[[6,8,8,17],"KĶ"],[[6,8,9,16],"_"],[[6,8,9,17],"ß"],[[6,9,8,15],"ť"],[[6,9,9,17],"fifl"],[[7,7,7,7],"❗"],[[7,7,8,16],"*^¬"],[[7,7,10,20],"÷∆≠"],[[7,8,7,14],"~"],[[7,8,8,15],"vyýÿŷ"],[[7,8,9,17],"Š"],[[7,8,9,18],"$ABCDGHNOPQRSUVXÀÁÂÃÄÅÇÑÒÓÔÕÖØÙÚÛÜÞĀĂĆĈĊČĎĐĜĞĠĢĤĦŃŅŇŊŌŎŐŔŖŘŚŜŞŨŪŬŮŰŲǾȘ"],[[7,8,9,19],"Ą€"],[[7,8,10,19],"Ð"],[[7,8,10,20],"+=Y¥±ÝŶŸ◊"],[[7,9,9,20],"#"],[[7,9,10,20],"IJ"],[[7,9,11,22],"–"],[[8,7,9,18],"«»"],[[8,7,10,20],"≈"],[[8,8,8,16],"¶"],[[8,8,10,20],"∏"],[[8,9,10,20],"&"],[[9,9,10,10],"►"],[[9,9,10,19],"☎"],[[9,10,11,21],"ď"],[[9,10,11,22],"®"],[[9,10,12,23],"mwæœŵǽ"],[[9,10,12,24],"MÆǼ…√"],[[10,10,11,18],"❤💙💚💛💜"],[[10,10,11,25],"♥"],[[10,10,12,24],"@¼"],[[10,10,12,25],"¾"],[[10,11,12,24],"½"],[[10,11,13,26],"Œ"],[[11,10,11,22],"™"],[[11,10,12,24],"∞"],[[11,11,11,16],"🍺"],[[11,11,11,17],"🖤"],[[11,11,11,18],"💔💗💝"],[[11,12,12,24],"WŴ"],[[11,12,14,28],"%"],[[12,12,12,14],"💡"],[[12,12,12,19],"💘"],[[12,12,13,17],"★📷"],[[12,12,13,18],"💖"],[[12,12,13,19],"😾🙁🙂"],[[12,12,13,25],"✈"],[[12,12,13,26],"©"],[[12,13,15,30],"—"],[[13,13,13,14],"☝"],[[13,13,13,15],"✅❎❣🤘"],[[13,13,13,16],"💟"],[[13,13,13,17],"⚧💀"],[[13,13,13,18],"😀😁😃😄😆😇😉😊😋😌😎😏😐😑😒😔😕😖😗😙😚😛😜😝😞😟😠😡😢😣😤😦😧😨😩😪😫😬😭😮😯😱😲😳😴😵😶😷🙃🤣🤤🤪🥺"],[[13,13,13,19],"☺"],[[13,13,13,20],"🌙🌷🌸🌺👀💣"],[[13,13,14,16],"🎥"],[[13,13,17,25],"⌛⌨⏏⏩⏪⏫⏬⏭⏮⏯⏰⏱⏲⏳⏸⏹⏺▪▫▶◀◻◼◽◾☀☁☂☃☄☑☔☕☘☢☣☦☪☮☯☸☹♀♂♈♉♊♋♌♍♎♏♐♑♒♓♟♠♣♦♨♻♾♿⚒⚓⚔⚕⚖⚗⚙⚛⚜⚠⚡⚪⚫⚰⚱⚽⚾⛄⛅⛈⛎⛏⛑⛓⛔⛩⛪⛰⛱⛲⛳⛴⛵⛷⛸⛹⛺⛽✂✉✍✏✒✔✖✝✡✳✴❄❇❌❓❔❕➕➖➗➡➰➿⬅⬆⬇⬛⬜⭕🅰🅱🅾🅿🆎🆑🆒🆓🆔🆕🆖🆗🆘🆙🆚🈁🈂🈚🈯🈲🈳🈴🈵🈶🈷🈸🈹🈺🉐🉑🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌚🌛🌜🌝🌞🌠🌡🌤🌥🌦🌧🌨🌩🌪🌫🌬🌭🌮🌯🌰🌱🌲🌳🌴🌵🌶🌹🌻🌼🌽🌾🌿🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓🍔🍕🍖🍗🍘🍙🍚🍛🍜🍝🍞🍟🍠🍡🍢🍣🍤🍥🍦🍧🍨🍩🍪🍫🍬🍭🍮🍯🍰🍱🍲🍳🍴🍵🍶🍷🍸🍹🍼🍽🍾🍿🎀🎂🎃🎄🎅🎆🎇🎈🎊🎋🎌🎍🎎🎏🎐🎑🎒🎓🎖🎗🎙🎚🎛🎞🎟🎠🎡🎢🎣🎦🎧🎨🎩🎪🎫🎬🎭🎮🎯🎰🎱🎲🎳🎴🎷🎸🎹🎺🎻🎼🎽🎾🎿🏀🏁🏂🏃🏄🏅🏆🏇🏈🏉🏊🏋🏌🏍🏎🏏🏐🏑🏒🏓🏔🏕🏖🏗🏘🏙🏚🏛🏜🏝🏞🏟🏠🏡🏢🏣🏤🏥🏦🏧🏨🏩🏪🏫🏬🏭🏮🏯🏰🏴🏵🏷🏸🏹🏺🐀🐁🐂🐃🐄🐅🐆🐇🐈🐉🐊🐋🐌🐍🐎🐏🐐🐑🐒🐓🐔🐕🐖🐗🐘🐙🐚🐛🐜🐝🐞🐟🐠🐡🐢🐣🐤🐦🐧🐨🐩🐪🐫🐬🐭🐮🐯🐰🐱🐲🐳🐴🐶🐷🐸🐹🐺🐻🐼🐽🐾🐿👁👂👃👄👅👑👒👓👔👕👖👗👘👙👚👛👜👝👞👟👠👡👢👣👤👥👦👧👨👩👪👫👬👭👮👯👰👱👲👳👴👵👶👷👸👹👺👻👼👽👾💁💂💃💄💅💆💇💈💉💊💌💍💎💏💐💑💒💠💢💤💦💧💨💫💬💭💮💰💱💲💳💴💵💶💷💸💹💺💻💼💽💾💿📀📁📂📃📄📅📆📇📈📉📊📋📌📍📎📏📐📑📒📓📔📕📖📗📘📙📚📛📜📝📞📟📠📡📢📣📤📥📦📧📨📩📪📫📬📭📮📯📰📱📲📳📴📵📶📸📹📺📻📼📽📿🔀🔁🔂🔃🔄🔅🔆🔇🔈🔉🔊🔋🔌🔍🔎🔏🔐🔑🔒🔓🔔🔕🔖🔗🔘🔙🔚🔛🔜🔝🔞🔟🔠🔡🔢🔣🔤🔦🔧🔨🔩🔪🔫🔬🔭🔮🔯🔰🔱🔲🔳🔴🔵🔶🔷🔸🔹🔺🔻🔼🔽🕉🕊🕋🕌🕍🕎🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧🕯🕰🕳🕴🕵🕶🕷🕸🕹🕺🖇🖊🖋🖌🖍🖐🖕🖖🖥🖨🖱🖲🖼🗂🗃🗄🗑🗒🗓🗜🗝🗞🗡🗣🗨🗯🗳🗺🗻🗼🗽🗾🗿🙅🙆🙇🙉🙊🙋🙍🙎🚀🚁🚂🚃🚄🚅🚆🚇🚈🚉🚊🚋🚌🚍🚎🚏🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🚝🚞🚟🚠🚡🚢🚣🚤🚥🚦🚧🚨🚩🚪🚫🚬🚭🚮🚯🚰🚱🚲🚳🚴🚵🚶🚷🚸🚹🚺🚻🚼🚽🚾🚿🛀🛁🛂🛃🛄🛅🛋🛌🛍🛎🛏🛐🛑🛒🛕🛖🛗🛜🛝🛞🛟🛠🛡🛢🛣🛤🛥🛩🛫🛬🛰🛳🛴🛵🛶🛷🛸🛹🛺🛻🛼🟠🟡🟢🟣🟤🟥🟦🟧🟨🟩🟪🟫🟰🤌🤍🤎🤏🤐🤑🤒🤓🤔🤕🤖🤙🤚🤛🤜🤞🤟🤠🤡🤢🤥🤦🤧🤨🤫🤭🤯🤰🤱🤲🤳🤴🤵🤶🤷🤸🤹🤺🤼🤽🤾🤿🥀🥁🥂🥃🥄🥅🥇🥈🥉🥊🥋🥌🥍🥎🥏🥐🥑🥒🥓🥔🥕🥖🥗🥘🥙🥚🥛🥜🥝🥞🥟🥠🥡🥢🥣🥤🥥🥦🥧🥨🥩🥪🥫🥬🥭🥮🥯🥱🥲🥳🥴🥵🥶🥷🥸🥹🥻🥼🥽🥾🥿🦀🦁🦂🦃🦄🦅🦆🦇🦈🦉🦊🦋🦌🦍🦎🦏🦐🦑🦒🦓🦔🦕🦖🦗🦘🦙🦚🦛🦜🦝🦞🦟🦠🦡🦢🦣🦤🦥🦦🦧🦨🦩🦪🦫🦬🦭🦮🦯🦰🦱🦲🦳🦴🦵🦶🦷🦸🦹🦺🦻🦼🦽🦾🦿🧀🧁🧂🧃🧄🧅🧆🧇🧈🧉🧊🧋🧌🧍🧎🧏🧐🧑🧒🧓🧔🧕🧖🧗🧘🧙🧚🧛🧜🧝🧞🧟🧠🧡🧢🧣🧤🧥🧦🧧🧨🧩🧪🧫🧬🧭🧮🧯🧰🧱🧲🧳🧴🧵🧶🧷🧸🧹🧺🧻🧼🧽🧾🧿🩰🩱🩲🩳🩴🩵🩶🩷🩸🩹🩺🩻🩼🪀🪁🪂🪃🪄🪅🪆🪇🪈🪐🪑🪒🪓🪔🪕🪖🪗🪘🪙🪚🪛🪜🪝🪞🪟🪠🪡🪢🪣🪤🪥🪦🪧🪨🪩🪪🪫🪬🪭🪮🪯🪰🪱🪲🪳🪴🪵🪶🪷🪸🪹🪺🪻🪼🪽🪿🫀🫁🫂🫃🫄🫅🫎🫏🫐🫑🫒🫓🫔🫕🫖🫗🫘🫙🫚🫛🫠🫡🫢🫣🫤🫥🫦🫧🫨🫰🫱🫲🫳🫴🫵🫶🫷🫸"],[[14,14,14,15],"✊✋✌"],[[14,14,14,17],"🐥💯"],[[14,14,14,18],"🎵"],[[14,14,14,19],"😸😺😻😼😽🙀"],[[14,14,14,20],"😿"],[[14,14,14,21],"✨"],[[14,14,14,22],"😹"],[[14,14,15,15],"→"],[[14,15,19,38],"‰"],[[15,15,15,13],"👊"],[[15,15,15,16],"👆👇"],[[15,15,15,17],"🍀"],[[15,15,15,18],"🙄"],[[15,15,15,19],"🎁💕💞"],[[15,15,15,22],"😂🤝"],[[15,15,16,20],"🐵🙈"],[[16,16,16,17],"👌"],[[16,16,16,18],"🇦🇧🇨🇩🇪🇫🇬🇭🇮🇯🇰🇱🇲🇳🇴🇵🇶🇷🇸🇹🇺🇻🇼🇽🇾🇿🏳"],[[16,16,16,19],"💩🔥"],[[16,16,16,20],"😅😓😥😰"],[[16,16,16,21],"⭐🌟😘"],[[17,17,17,18],"💪🤮"],[[17,17,17,20],"😈🤗🤬"],[[17,17,17,21],"🎉👿💥"],[[17,17,17,22],"😍🙏🤩"],[[17,17,17,23],"☠"],[[18,18,18,19],"🎶👈👉👋👍👎"],[[19,19,19,19],"⌚"],[[19,19,19,20],"🍻💓"],[[19,19,19,22],"🥰"],[[20,20,20,21],"👏💋"],[[23,23,23,24],"🙌"],[[27,27,27,28],"👐"]],"note":"Exact native single-codepoint widths. Whole string shaping/control/layout rules are separate, not globally additive."} | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
packages/companion/src/display-fonts.js +77 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/display-layout.js +221 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/display.js +117 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/src/index.js +842 -435 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/src/message-queue.js +89 -98 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/prim-client.js +432 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/prim-departures.js +215 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/prim-traffic.js +314 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/test/companion.test.js +675 -1209 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/test/fakes.js +12 -2 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/test/prim-client.test.js +476 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/test/prim-normalization.test.js +351 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/test/prim-traffic-empty-periods.test.js +113 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/companion/test/protocol.test.js +182 -233 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/index.html +12 -0 | @@ -71,6 +71,18 @@ | ||
| 71 | 71 | <button type="button" id="favorite-add" class="secondary"></button> |
| 72 | 72 | </div> |
| 73 | 73 | </section> |
| 74 | + | |
| 75 | + <section aria-labelledby="sync-title" id="sync-section"> | |
| 76 | + <div class="section-heading"> | |
| 77 | + <span class="step" aria-hidden="true">04</span> | |
| 78 | + <h2 id="sync-title">Watch synchronization</h2> | |
| 79 | + </div> | |
| 80 | + <label class="check-row" for="force-sync"> | |
| 81 | + <input id="force-sync" type="checkbox"> | |
| 82 | + <span id="force-sync-label"></span> | |
| 83 | + </label> | |
| 84 | + <p class="support" id="sync-hint"></p> | |
| 85 | + </section> | |
| 74 | 86 | </div> |
| 75 | 87 | |
| 76 | 88 | <section id="about-view" class="about-view" aria-labelledby="about-title" hidden> |
| @@ -71,6 +71,18 @@ | |||
| 71 | <button type="button" id="favorite-add" class="secondary"></button> | 71 | <button type="button" id="favorite-add" class="secondary"></button> |
| 72 | </div> | 72 | </div> |
| 73 | </section> | 73 | </section> |
| 74 | + | ||
| 75 | + <section aria-labelledby="sync-title" id="sync-section"> | ||
| 76 | + <div class="section-heading"> | ||
| 77 | + <span class="step" aria-hidden="true">04</span> | ||
| 78 | + <h2 id="sync-title">Watch synchronization</h2> | ||
| 79 | + </div> | ||
| 80 | + <label class="check-row" for="force-sync"> | ||
| 81 | + <input id="force-sync" type="checkbox"> | ||
| 82 | + <span id="force-sync-label"></span> | ||
| 83 | + </label> | ||
| 84 | + <p class="support" id="sync-hint"></p> | ||
| 85 | + </section> | ||
| 74 | </div> | 86 | </div> |
| 75 | 87 | ||
| 76 | <section id="about-view" class="about-view" aria-labelledby="about-title" hidden> | 88 | <section id="about-view" class="about-view" aria-labelledby="about-title" hidden> |
modified
packages/config-page/src/catalog-client.js +192 -35 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/src/config-core.js +131 -20 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/src/config-page.js +53 -1 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/src/search-text.js +15 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/styles/config-page.css +19 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/test/catalog-client.test.js +212 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/test/config-page.test.ts +366 -80 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/src/index.ts +563 -371 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/test/contracts.test.ts +571 -107 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/.lock-waf_linux_build +1 -1 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/package.json +12 -18 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/prototype-issue-16.html +681 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/c/mdbl.c +3 -3 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/contracts.js +0 -139 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/controller.js +0 -236 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/localization.js +0 -69 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/main.js +34 -80 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/manifest.json +2 -14 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/message-queue.js +0 -96 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/model.js +0 -246 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/packed.js +192 -0 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/presentation.js +0 -108 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/src/embeddedjs/protocol.js +0 -632 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/runtime.js +350 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/storage.js +104 -280 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/src/embeddedjs/ui.js +208 -316 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/controller-storage.test.js +0 -692 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/d2-records.js +212 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/display-copy.test.js +49 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/fixture-boot.test.js +231 -508 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/localization.test.js +0 -84 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/main.test.js +92 -0 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/manifest.test.js +0 -181 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/message-queue.test.js +0 -118 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/model.test.js +0 -397 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/native-host.js +124 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/packed.test.js +135 -0 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/presentation.test.js +0 -527 Large diff collapsed to keep the page fast — load it when you need it.
deleted
packages/watch/test/protocol.test.js +0 -437 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/runtime.test.js +626 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/storage.test.js +162 -105 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/ui.test.js +122 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/xs-host.js +31 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/build-config-site.mjs +70 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/build.mjs +11 -8 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/bundle-watch.mjs +20 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/generate-display-copy.mjs +76 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/generate-paris-time.mjs +91 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/measure-catalog.mjs +3 -2 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/measure-radio.mjs +114 -369 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/probe-catalog.mjs +1 -1 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/refresh-catalog.mjs +33 -5 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/test.mjs +20 -23 Large diff collapsed to keep the page fast — load it when you need it.
modified
scripts/verify-watch-build.mjs +13 -16 Large diff collapsed to keep the page fast — load it when you need it.