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

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

Download ZIP

feat: harden stale and failure handling #20

Merged@github-anthodev wants to merge feature/harden-failures/reliability/8 into develop
Merged as 835b18ba053b.
30 files changed
added .github/workflows/deploy-bunny.yml +220 -0
new file mode 100644
@@ -0,0 +1,220 @@
1+name: Deploy configuration site to Bunny
2+
3+on:
4+ push:
5+ tags: ["v*", "pre-v*"]
6+ workflow_dispatch:
7+ inputs:
8+ refresh_catalog:
9+ description: Rebuild and publish the IDFM catalog
10+ required: true
11+ default: false
12+ type: boolean
13+
14+permissions:
15+ contents: read
16+
17+concurrency:
18+ group: deploy-bunny-production
19+ # Never interrupt a production upload between its immutable files and the
20+ # manifest publication. A later deployment waits for the current one.
21+ cancel-in-progress: false
22+
23+jobs:
24+ test:
25+ name: Run quality checks
26+ runs-on: ubuntu-latest
27+ steps:
28+ - uses: actions/checkout@v4
29+
30+ - uses: actions/setup-node@v4
31+ with:
32+ node-version-file: .node-version
33+ cache: npm
34+
35+ - run: npm ci
36+
37+ - name: Validate release tag
38+ if: ${{ github.event_name == 'push' }}
39+ env:
40+ RELEASE_TAG: ${{ github.ref_name }}
41+ run: |
42+ production='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
43+ preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
44+ [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]]
45+
46+ - run: npm test
47+
48+ deploy:
49+ name: Build and deploy configuration site
50+ needs: test
51+ if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }}
52+ runs-on: ubuntu-latest
53+ environment: production
54+ steps:
55+ - uses: actions/checkout@v4
56+
57+ - uses: actions/setup-node@v4
58+ with:
59+ node-version-file: .node-version
60+ cache: npm
61+
62+ - run: npm ci
63+
64+ - name: Refresh production catalog
65+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}
66+ env:
67+ IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
68+ run: npm run catalog:refresh
69+
70+ - name: Build static configuration site
71+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}
72+ run: npm run build:config-site
73+
74+ - name: Build configuration page
75+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.refresh_catalog != true }}
76+ run: npm run build:config-page
77+
78+ - name: Upload static site to Bunny Storage
79+ shell: bash
80+ env:
81+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
82+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
83+ run: |
84+ set -euo pipefail
85+
86+ test -n "$BUNNY_STORAGE_ENDPOINT"
87+ test -n "$BUNNY_STORAGE_PASSWORD"
88+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
89+ case "$endpoint" in
90+ https://*) ;;
91+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
92+ esac
93+
94+ upload_file() {
95+ local file="$1"
96+ local relative_path="${file#var/config-site/}"
97+ local mime_type
98+ case "$relative_path" in
99+ *[!A-Za-z0-9._/-]* | /* | *../*)
100+ echo "Refusing unsafe deployment path: $relative_path" >&2
101+ exit 1
102+ ;;
103+ esac
104+ mime_type="$(file --brief --mime-type "$file")"
105+ echo "Uploading $relative_path"
106+ curl --fail --silent --show-error \
107+ --retry 3 --retry-all-errors \
108+ --request PUT \
109+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
110+ --header "Content-Type: $mime_type" \
111+ --upload-file "$file" \
112+ "$endpoint/$relative_path"
113+ }
114+
115+ export endpoint BUNNY_STORAGE_PASSWORD
116+ export -f upload_file
117+ find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \
118+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \
119+ bash -c 'upload_file "$1"' _
120+
121+ # Publish the catalog pointer only after every immutable catalog file.
122+ if [[ -f var/config-site/catalog/manifest.json ]]; then
123+ upload_file var/config-site/catalog/manifest.json
124+ fi
125+
126+ - name: Purge Bunny CDN cache
127+ env:
128+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
129+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
130+ run: |
131+ set -euo pipefail
132+ test -n "$BUNNY_API_KEY"
133+ test -n "$BUNNY_PULL_ZONE_ID"
134+ curl --fail --silent --show-error \
135+ --retry 3 --retry-all-errors \
136+ --request POST \
137+ --header "AccessKey: $BUNNY_API_KEY" \
138+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
139+
140+ publish:
141+ name: Publish GitHub release notes
142+ needs: [test, deploy]
143+ if: ${{ startsWith(github.ref_name, 'v') }}
144+ runs-on: ubuntu-latest
145+ permissions:
146+ contents: write
147+ steps:
148+ - name: Check out tagged commit
149+ uses: actions/checkout@v4
150+ with:
151+ ref: ${{ github.sha }}
152+ fetch-depth: 0
153+ persist-credentials: false
154+
155+ - name: Generate categorized release notes
156+ shell: bash
157+ run: |
158+ set -euo pipefail
159+ previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)"
160+
161+ if [[ -n "$previous_tag" ]]; then
162+ range="$previous_tag..$GITHUB_SHA"
163+ changelog_url="https://github.com/$GITHUB_REPOSITORY/compare/$previous_tag...$GITHUB_REF_NAME"
164+ else
165+ range="$GITHUB_SHA"
166+ changelog_url="https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME"
167+ fi
168+
169+ RANGE="$range" CHANGELOG_URL="$changelog_url" node --input-type=module <<'EOF'
170+ import { execFileSync } from "node:child_process";
171+ import { writeFileSync } from "node:fs";
172+
173+ const groups = [
174+ { key: "feat", title: "Features", commits: [] },
175+ { key: "change", title: "Changes", commits: [] },
176+ { key: "fix", title: "Fixes", commits: [] },
177+ { key: "ci", title: "CI", commits: [] },
178+ ];
179+ const byKey = new Map(groups.map((group) => [group.key, group]));
180+ const fields = execFileSync(
181+ "git",
182+ ["log", "--first-parent", "-z", "--format=%s%x00%h", process.env.RANGE],
183+ { encoding: "utf8" },
184+ ).split("\0").filter(Boolean);
185+
186+ for (let index = 0; index < fields.length; index += 2) {
187+ const subject = fields[index].trim();
188+ const hash = fields[index + 1].trim();
189+ const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
190+ const key = type === "feat"
191+ ? "feat"
192+ : type === "fix"
193+ ? "fix"
194+ : type === "ci"
195+ ? "ci"
196+ : "change";
197+
198+ byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
199+ }
200+
201+ const notes = [];
202+ for (const group of groups) {
203+ if (group.commits.length === 0) continue;
204+ notes.push(`## ${group.title}`, "", ...group.commits, "");
205+ }
206+ notes.push(`**Full changelog**: ${process.env.CHANGELOG_URL}`, "");
207+ writeFileSync("release-notes.md", notes.join("\n"));
208+ EOF
209+
210+ - name: Create release with categorized notes
211+ env:
212+ GH_TOKEN: ${{ github.token }}
213+ shell: bash
214+ run: |
215+ set -euo pipefail
216+ gh release create "$GITHUB_REF_NAME" \
217+ --repo "$GITHUB_REPOSITORY" \
218+ --title "Lapin Futé $GITHUB_REF_NAME" \
219+ --notes-file release-notes.md \
220+ --verify-tag
new file mode 100644
@@ -0,0 +1,220 @@
1+name: Deploy configuration site to Bunny
2+
3+on:
4+ push:
5+ tags: ["v*", "pre-v*"]
6+ workflow_dispatch:
7+ inputs:
8+ refresh_catalog:
9+ description: Rebuild and publish the IDFM catalog
10+ required: true
11+ default: false
12+ type: boolean
13+
14+permissions:
15+ contents: read
16+
17+concurrency:
18+ group: deploy-bunny-production
19+ # Never interrupt a production upload between its immutable files and the
20+ # manifest publication. A later deployment waits for the current one.
21+ cancel-in-progress: false
22+
23+jobs:
24+ test:
25+ name: Run quality checks
26+ runs-on: ubuntu-latest
27+ steps:
28+ - uses: actions/checkout@v4
29+
30+ - uses: actions/setup-node@v4
31+ with:
32+ node-version-file: .node-version
33+ cache: npm
34+
35+ - run: npm ci
36+
37+ - name: Validate release tag
38+ if: ${{ github.event_name == 'push' }}
39+ env:
40+ RELEASE_TAG: ${{ github.ref_name }}
41+ run: |
42+ production='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
43+ preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
44+ [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]]
45+
46+ - run: npm test
47+
48+ deploy:
49+ name: Build and deploy configuration site
50+ needs: test
51+ if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }}
52+ runs-on: ubuntu-latest
53+ environment: production
54+ steps:
55+ - uses: actions/checkout@v4
56+
57+ - uses: actions/setup-node@v4
58+ with:
59+ node-version-file: .node-version
60+ cache: npm
61+
62+ - run: npm ci
63+
64+ - name: Refresh production catalog
65+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}
66+ env:
67+ IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
68+ run: npm run catalog:refresh
69+
70+ - name: Build static configuration site
71+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}
72+ run: npm run build:config-site
73+
74+ - name: Build configuration page
75+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.refresh_catalog != true }}
76+ run: npm run build:config-page
77+
78+ - name: Upload static site to Bunny Storage
79+ shell: bash
80+ env:
81+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
82+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
83+ run: |
84+ set -euo pipefail
85+
86+ test -n "$BUNNY_STORAGE_ENDPOINT"
87+ test -n "$BUNNY_STORAGE_PASSWORD"
88+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
89+ case "$endpoint" in
90+ https://*) ;;
91+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
92+ esac
93+
94+ upload_file() {
95+ local file="$1"
96+ local relative_path="${file#var/config-site/}"
97+ local mime_type
98+ case "$relative_path" in
99+ *[!A-Za-z0-9._/-]* | /* | *../*)
100+ echo "Refusing unsafe deployment path: $relative_path" >&2
101+ exit 1
102+ ;;
103+ esac
104+ mime_type="$(file --brief --mime-type "$file")"
105+ echo "Uploading $relative_path"
106+ curl --fail --silent --show-error \
107+ --retry 3 --retry-all-errors \
108+ --request PUT \
109+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
110+ --header "Content-Type: $mime_type" \
111+ --upload-file "$file" \
112+ "$endpoint/$relative_path"
113+ }
114+
115+ export endpoint BUNNY_STORAGE_PASSWORD
116+ export -f upload_file
117+ find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \
118+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \
119+ bash -c 'upload_file "$1"' _
120+
121+ # Publish the catalog pointer only after every immutable catalog file.
122+ if [[ -f var/config-site/catalog/manifest.json ]]; then
123+ upload_file var/config-site/catalog/manifest.json
124+ fi
125+
126+ - name: Purge Bunny CDN cache
127+ env:
128+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
129+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
130+ run: |
131+ set -euo pipefail
132+ test -n "$BUNNY_API_KEY"
133+ test -n "$BUNNY_PULL_ZONE_ID"
134+ curl --fail --silent --show-error \
135+ --retry 3 --retry-all-errors \
136+ --request POST \
137+ --header "AccessKey: $BUNNY_API_KEY" \
138+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
139+
140+ publish:
141+ name: Publish GitHub release notes
142+ needs: [test, deploy]
143+ if: ${{ startsWith(github.ref_name, 'v') }}
144+ runs-on: ubuntu-latest
145+ permissions:
146+ contents: write
147+ steps:
148+ - name: Check out tagged commit
149+ uses: actions/checkout@v4
150+ with:
151+ ref: ${{ github.sha }}
152+ fetch-depth: 0
153+ persist-credentials: false
154+
155+ - name: Generate categorized release notes
156+ shell: bash
157+ run: |
158+ set -euo pipefail
159+ previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "$GITHUB_SHA^" 2>/dev/null || true)"
160+
161+ if [[ -n "$previous_tag" ]]; then
162+ range="$previous_tag..$GITHUB_SHA"
163+ changelog_url="https://github.com/$GITHUB_REPOSITORY/compare/$previous_tag...$GITHUB_REF_NAME"
164+ else
165+ range="$GITHUB_SHA"
166+ changelog_url="https://github.com/$GITHUB_REPOSITORY/commits/$GITHUB_REF_NAME"
167+ fi
168+
169+ RANGE="$range" CHANGELOG_URL="$changelog_url" node --input-type=module <<'EOF'
170+ import { execFileSync } from "node:child_process";
171+ import { writeFileSync } from "node:fs";
172+
173+ const groups = [
174+ { key: "feat", title: "Features", commits: [] },
175+ { key: "change", title: "Changes", commits: [] },
176+ { key: "fix", title: "Fixes", commits: [] },
177+ { key: "ci", title: "CI", commits: [] },
178+ ];
179+ const byKey = new Map(groups.map((group) => [group.key, group]));
180+ const fields = execFileSync(
181+ "git",
182+ ["log", "--first-parent", "-z", "--format=%s%x00%h", process.env.RANGE],
183+ { encoding: "utf8" },
184+ ).split("\0").filter(Boolean);
185+
186+ for (let index = 0; index < fields.length; index += 2) {
187+ const subject = fields[index].trim();
188+ const hash = fields[index + 1].trim();
189+ const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
190+ const key = type === "feat"
191+ ? "feat"
192+ : type === "fix"
193+ ? "fix"
194+ : type === "ci"
195+ ? "ci"
196+ : "change";
197+
198+ byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
199+ }
200+
201+ const notes = [];
202+ for (const group of groups) {
203+ if (group.commits.length === 0) continue;
204+ notes.push(`## ${group.title}`, "", ...group.commits, "");
205+ }
206+ notes.push(`**Full changelog**: ${process.env.CHANGELOG_URL}`, "");
207+ writeFileSync("release-notes.md", notes.join("\n"));
208+ EOF
209+
210+ - name: Create release with categorized notes
211+ env:
212+ GH_TOKEN: ${{ github.token }}
213+ shell: bash
214+ run: |
215+ set -euo pipefail
216+ gh release create "$GITHUB_REF_NAME" \
217+ --repo "$GITHUB_REPOSITORY" \
218+ --title "Lapin Futé $GITHUB_REF_NAME" \
219+ --notes-file release-notes.md \
220+ --verify-tag
modified README.md +1 -1
@@ -40,7 +40,7 @@ The name nods to Bison Futé, the French road-traffic information service, and t
4040 - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
4141 - **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes.
4242 - **Traffic view.** Select again on a departure board to page through the current service messages for that line.
43-- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling.
43+- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
4444 - **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data.
4545 - **English and French.** The watch interface and the settings page are both bilingual.
4646 - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.
@@ -40,7 +40,7 @@ The name nods to Bison Futé, the French road-traffic information service, and t
40 - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.40 - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
41 - **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes.41 - **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes.
42 - **Traffic view.** Select again on a departure board to page through the current service messages for that line.42 - **Traffic view.** Select again on a departure board to page through the current service messages for that line.
43-- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling.43+- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling.
44 - **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data.44 - **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data.
45 - **English and French.** The watch interface and the settings page are both bilingual.45 - **English and French.** The watch interface and the settings page are both bilingual.
46 - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.46 - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.
modified package.json +1 -0
@@ -11,6 +11,7 @@
1111 },
1212 "scripts": {
1313 "build": "node scripts/build.mjs",
14+ "build:config-page": "node scripts/build-config-site.mjs --page-only",
1415 "build:config-site": "node scripts/build-config-site.mjs",
1516 "catalog:probe": "node scripts/probe-catalog.mjs",
1617 "catalog:refresh": "node scripts/refresh-catalog.mjs",
@@ -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-page": "node scripts/build-config-site.mjs --page-only",
14 "build:config-site": "node scripts/build-config-site.mjs",15 "build:config-site": "node scripts/build-config-site.mjs",
15 "catalog:probe": "node scripts/probe-catalog.mjs",16 "catalog:probe": "node scripts/probe-catalog.mjs",
16 "catalog:refresh": "node scripts/refresh-catalog.mjs",17 "catalog:refresh": "node scripts/refresh-catalog.mjs",
modified packages/catalog/src/catalog-import.ts +1 -1
@@ -1062,7 +1062,7 @@ function deriveCandidates(database: DatabaseSync): void {
10621062 zone.zdaname AS stop_label, zone.zdatown AS locality_label,
10631063 line_mode.id_line AS id_line, line_mode.line_label AS line_label,
10641064 line_mode.line_color AS line_color, line_mode.line_text_color AS line_text_color,
1065- CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN trip.terminal_stop_name
1065+ CASE WHEN line_mode.mode IN ('BUS', 'RER', 'TRANSILIEN') THEN trip.terminal_stop_name
10661066 ELSE coalesce(nullif(trim(trip.trip_headsign), ''), trip.terminal_stop_name)
10671067 END AS destination_label,
10681068 CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN')
@@ -1062,7 +1062,7 @@ function deriveCandidates(database: DatabaseSync): void {
1062 zone.zdaname AS stop_label, zone.zdatown AS locality_label,1062 zone.zdaname AS stop_label, zone.zdatown AS locality_label,
1063 line_mode.id_line AS id_line, line_mode.line_label AS line_label,1063 line_mode.id_line AS id_line, line_mode.line_label AS line_label,
1064 line_mode.line_color AS line_color, line_mode.line_text_color AS line_text_color,1064 line_mode.line_color AS line_color, line_mode.line_text_color AS line_text_color,
1065- CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN trip.terminal_stop_name1065+ CASE WHEN line_mode.mode IN ('BUS', 'RER', 'TRANSILIEN') THEN trip.terminal_stop_name
1066 ELSE coalesce(nullif(trim(trip.trip_headsign), ''), trip.terminal_stop_name)1066 ELSE coalesce(nullif(trim(trip.trip_headsign), ''), trip.terminal_stop_name)
1067 END AS destination_label,1067 END AS destination_label,
1068 CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN')1068 CASE WHEN line_mode.mode IN ('RER', 'TRANSILIEN')
modified packages/catalog/test/catalog-import.test.ts +56 -0
@@ -134,6 +134,62 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves
134134 assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false);
135135 });
136136
137+test("bus services use their actual terminal to distinguish partial trips with the same headsign", async (t) => {
138+ const directory = workspace(t);
139+ const candidate = join(directory, "candidate.sqlite");
140+ const sources = sourcesFor("fixture-2026-08-a");
141+ await buildCatalogCandidateFromRecords({
142+ candidatePath: candidate,
143+ sourceRevision: "fixture-bus-partial-trip",
144+ sources: {
145+ ...sources,
146+ stops: [
147+ ...sources.stops,
148+ { stop_id: "IDFM:TERM-BUS-PARTIAL", stop_name: "Porte de Paris", parent_station: "" },
149+ ],
150+ trips: [
151+ ...sources.trips,
152+ {
153+ route_id: "IDFM:C100",
154+ trip_id: "trip-bus-partial",
155+ trip_headsign: "Hôpital Européen",
156+ direction_id: "0",
157+ },
158+ ],
159+ stopTimes: [
160+ ...sources.stopTimes,
161+ { trip_id: "trip-bus-partial", stop_id: "IDFM:ART-BUS-1", stop_sequence: "1", pickup_type: "0" },
162+ { trip_id: "trip-bus-partial", stop_id: "IDFM:TERM-BUS-PARTIAL", stop_sequence: "2", pickup_type: "1" },
163+ ],
164+ objectCodes: [
165+ ...sources.objectCodes,
166+ {
167+ object_type: "StopPoint",
168+ object_id: "IDFM:TERM-BUS-PARTIAL",
169+ object_system: "source",
170+ object_code: "DEST:BUS-PARTIAL",
171+ },
172+ ],
173+ },
174+ });
175+
176+ const manager = new CatalogManager();
177+ manager.reload(candidate);
178+ const bus = place(manager, "gare du nord", "BUS", "Paris");
179+ const services = manager.listServices(bus.placeId) ?? [];
180+ assert.deepEqual(
181+ services.map((entry) => entry.destinationLabel).sort(),
182+ ["Hôpital Européen", "Porte de Paris"],
183+ );
184+ assert.notEqual(services[0]?.serviceId, services[1]?.serviceId);
185+ assert.deepEqual(
186+ new Set(services.map((entry) => manager.resolveService(entry.serviceId))
187+ .filter((entry) => entry.status === "RESOLVED")
188+ .map((entry) => entry.destinationRef)),
189+ new Set(["STIF:StopPoint:Q:TERM-BUS-1:", "STIF:StopPoint:Q:TERM-BUS-PARTIAL:"]),
190+ );
191+});
192+
137193 test("GTFS quay and monomodal stops join SIRI perimeter namespaces without rewriting routing references", async (t) => {
138194 const directory = workspace(t);
139195 const sources = sourcesFor("fixture-2026-08-a");
@@ -134,6 +134,62 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves
134 assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false);134 assert.equal(busServices.some((entry) => entry.destinationLabel === "Wrong perimeter destination"), false);
135 });135 });
136 136
137+test("bus services use their actual terminal to distinguish partial trips with the same headsign", async (t) => {
138+ const directory = workspace(t);
139+ const candidate = join(directory, "candidate.sqlite");
140+ const sources = sourcesFor("fixture-2026-08-a");
141+ await buildCatalogCandidateFromRecords({
142+ candidatePath: candidate,
143+ sourceRevision: "fixture-bus-partial-trip",
144+ sources: {
145+ ...sources,
146+ stops: [
147+ ...sources.stops,
148+ { stop_id: "IDFM:TERM-BUS-PARTIAL", stop_name: "Porte de Paris", parent_station: "" },
149+ ],
150+ trips: [
151+ ...sources.trips,
152+ {
153+ route_id: "IDFM:C100",
154+ trip_id: "trip-bus-partial",
155+ trip_headsign: "Hôpital Européen",
156+ direction_id: "0",
157+ },
158+ ],
159+ stopTimes: [
160+ ...sources.stopTimes,
161+ { trip_id: "trip-bus-partial", stop_id: "IDFM:ART-BUS-1", stop_sequence: "1", pickup_type: "0" },
162+ { trip_id: "trip-bus-partial", stop_id: "IDFM:TERM-BUS-PARTIAL", stop_sequence: "2", pickup_type: "1" },
163+ ],
164+ objectCodes: [
165+ ...sources.objectCodes,
166+ {
167+ object_type: "StopPoint",
168+ object_id: "IDFM:TERM-BUS-PARTIAL",
169+ object_system: "source",
170+ object_code: "DEST:BUS-PARTIAL",
171+ },
172+ ],
173+ },
174+ });
175+
176+ const manager = new CatalogManager();
177+ manager.reload(candidate);
178+ const bus = place(manager, "gare du nord", "BUS", "Paris");
179+ const services = manager.listServices(bus.placeId) ?? [];
180+ assert.deepEqual(
181+ services.map((entry) => entry.destinationLabel).sort(),
182+ ["Hôpital Européen", "Porte de Paris"],
183+ );
184+ assert.notEqual(services[0]?.serviceId, services[1]?.serviceId);
185+ assert.deepEqual(
186+ new Set(services.map((entry) => manager.resolveService(entry.serviceId))
187+ .filter((entry) => entry.status === "RESOLVED")
188+ .map((entry) => entry.destinationRef)),
189+ new Set(["STIF:StopPoint:Q:TERM-BUS-1:", "STIF:StopPoint:Q:TERM-BUS-PARTIAL:"]),
190+ );
191+});
192+
137 test("GTFS quay and monomodal stops join SIRI perimeter namespaces without rewriting routing references", async (t) => {193 test("GTFS quay and monomodal stops join SIRI perimeter namespaces without rewriting routing references", async (t) => {
138 const directory = workspace(t);194 const directory = workspace(t);
139 const sources = sourcesFor("fixture-2026-08-a");195 const sources = sourcesFor("fixture-2026-08-a");
modified packages/companion/src/configuration.js +9 -5
@@ -675,21 +675,25 @@ function applyConfigurationUpdate(current, update) {
675675
676676 function parseCloseFragment(response) {
677677 var fragment;
678- var hashIndex;
679678 var parsed;
679+ var closePrefix = "pebblejs://close#";
680680 if (typeof response !== "string"
681681 || response.length === 0
682682 || response.length > MAX_CLOSE_RESPONSE_LENGTH
683683 || response === "CANCELLED") return null;
684- hashIndex = response.indexOf("#");
685- fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1);
684+ fragment = response.indexOf(closePrefix) === 0
685+ ? response.slice(closePrefix.length)
686+ : response;
686687 if (fragment.charAt(0) === "#") fragment = fragment.slice(1);
687688 if (fragment.length === 0 || fragment === "CANCELLED") return null;
688689 try {
689- fragment = decodeURIComponent(fragment);
690690 parsed = JSON.parse(fragment);
691691 } catch (ignored) {
692- return null;
692+ try {
693+ parsed = JSON.parse(decodeURIComponent(fragment));
694+ } catch (alsoIgnored) {
695+ return null;
696+ }
693697 }
694698 if (!isConfigurationUpdate(parsed)) return null;
695699 return {
@@ -675,21 +675,25 @@ function applyConfigurationUpdate(current, update) {
675 675
676 function parseCloseFragment(response) {676 function parseCloseFragment(response) {
677 var fragment;677 var fragment;
678- var hashIndex;
679 var parsed;678 var parsed;
679+ var closePrefix = "pebblejs://close#";
680 if (typeof response !== "string"680 if (typeof response !== "string"
681 || response.length === 0681 || response.length === 0
682 || response.length > MAX_CLOSE_RESPONSE_LENGTH682 || response.length > MAX_CLOSE_RESPONSE_LENGTH
683 || response === "CANCELLED") return null;683 || response === "CANCELLED") return null;
684- hashIndex = response.indexOf("#");684+ fragment = response.indexOf(closePrefix) === 0
685- fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1);685+ ? response.slice(closePrefix.length)
686+ : response;
686 if (fragment.charAt(0) === "#") fragment = fragment.slice(1);687 if (fragment.charAt(0) === "#") fragment = fragment.slice(1);
687 if (fragment.length === 0 || fragment === "CANCELLED") return null;688 if (fragment.length === 0 || fragment === "CANCELLED") return null;
688 try {689 try {
689- fragment = decodeURIComponent(fragment);
690 parsed = JSON.parse(fragment);690 parsed = JSON.parse(fragment);
691 } catch (ignored) {691 } catch (ignored) {
692- return null;692+ try {
693+ parsed = JSON.parse(decodeURIComponent(fragment));
694+ } catch (alsoIgnored) {
695+ return null;
696+ }
693 }697 }
694 if (!isConfigurationUpdate(parsed)) return null;698 if (!isConfigurationUpdate(parsed)) return null;
695 return {699 return {
modified packages/companion/src/contracts.js +2 -0
@@ -16,6 +16,7 @@ var LIMITS = Object.freeze({
1616 trafficTextUtf8Bytes: 384
1717 });
1818 var CACHE_FRESH_SECONDS = 60;
19+var USEFUL_STALE_SECONDS = 15 * 60;
1920 var FAVORITE_SETTLE_MS = 500;
2021 var APP_MESSAGE_INBOX_BYTES = 768;
2122 var APP_MESSAGE_OUTBOX_BYTES = 192;
@@ -694,6 +695,7 @@ module.exports = {
694695 SCHEMA_VERSION: SCHEMA_VERSION,
695696 LIMITS: LIMITS,
696697 CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS,
698+ USEFUL_STALE_SECONDS: USEFUL_STALE_SECONDS,
697699 FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS,
698700 APP_MESSAGE_INBOX_BYTES: APP_MESSAGE_INBOX_BYTES,
699701 APP_MESSAGE_OUTBOX_BYTES: APP_MESSAGE_OUTBOX_BYTES,
@@ -16,6 +16,7 @@ var LIMITS = Object.freeze({
16 trafficTextUtf8Bytes: 38416 trafficTextUtf8Bytes: 384
17 });17 });
18 var CACHE_FRESH_SECONDS = 60;18 var CACHE_FRESH_SECONDS = 60;
19+var USEFUL_STALE_SECONDS = 15 * 60;
19 var FAVORITE_SETTLE_MS = 500;20 var FAVORITE_SETTLE_MS = 500;
20 var APP_MESSAGE_INBOX_BYTES = 768;21 var APP_MESSAGE_INBOX_BYTES = 768;
21 var APP_MESSAGE_OUTBOX_BYTES = 192;22 var APP_MESSAGE_OUTBOX_BYTES = 192;
@@ -694,6 +695,7 @@ module.exports = {
694 SCHEMA_VERSION: SCHEMA_VERSION,695 SCHEMA_VERSION: SCHEMA_VERSION,
695 LIMITS: LIMITS,696 LIMITS: LIMITS,
696 CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS,697 CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS,
698+ USEFUL_STALE_SECONDS: USEFUL_STALE_SECONDS,
697 FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS,699 FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS,
698 APP_MESSAGE_INBOX_BYTES: APP_MESSAGE_INBOX_BYTES,700 APP_MESSAGE_INBOX_BYTES: APP_MESSAGE_INBOX_BYTES,
699 APP_MESSAGE_OUTBOX_BYTES: APP_MESSAGE_OUTBOX_BYTES,701 APP_MESSAGE_OUTBOX_BYTES: APP_MESSAGE_OUTBOX_BYTES,
modified packages/companion/src/display-copy.json +2 -2
@@ -56,8 +56,8 @@
5656 },
5757 {
5858 "token": "noDepartures",
59- "en": "No departures announced",
60- "fr": "Aucun départ annoncé"
59+ "en": "No departures planned",
60+ "fr": "Aucun départ prévu"
6161 },
6262 {
6363 "token": "departed",
@@ -56,8 +56,8 @@
56 },56 },
57 {57 {
58 "token": "noDepartures",58 "token": "noDepartures",
59- "en": "No departures announced",59+ "en": "No departures planned",
60- "fr": "Aucun départ annoncé"60+ "fr": "Aucun départ prévu"
61 },61 },
62 {62 {
63 "token": "departed",63 "token": "departed",
modified packages/companion/src/index.js +17 -2
@@ -71,11 +71,20 @@ function normalizeIncomingPayload(payload) {
7171 return aliasCount > 0 ? normalized : null;
7272 }
7373
74+function cacheAge(timestamp, storedAt, now) {
75+ return now - Math.min(timestamp * 1000, storedAt);
76+}
77+
7478 function cacheFresh(timestamp, storedAt, now) {
75- var age = now - Math.min(timestamp * 1000, storedAt);
79+ var age = cacheAge(timestamp, storedAt, now);
7680 return age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000;
7781 }
7882
83+function cacheUseful(timestamp, storedAt, now) {
84+ var age = cacheAge(timestamp, storedAt, now);
85+ return age >= 0 && age < contracts.USEFUL_STALE_SECONDS * 1000;
86+}
87+
7988 function sameFavoriteContentSet(left, right) {
8089 if (left.length !== right.length) return false;
8190 return left.every(function (favorite) {
@@ -159,6 +168,7 @@ function Companion(options) {
159168 cacheMisses: 0,
160169 successes: 0,
161170 failures: 0,
171+ rateLimitedResponses: 0,
162172 totalLatencyMs: 0,
163173 lastLatencyMs: 0
164174 };
@@ -550,7 +560,8 @@ Companion.prototype._favoriteView = function (favorite, flightErrors, aggregateE
550560 if (entry !== null && entry.serviceId !== favorite.serviceId) entry = null;
551561 var code = aggregateError || (flightErrors && flightErrors[favorite.serviceId]) || null;
552562 if (!code && entry && entry.refreshError) code = entry.refreshError.code;
553- var hasData = entry !== null && hasOwn.call(entry, "result");
563+ var hasData = entry !== null && hasOwn.call(entry, "result")
564+ && cacheUseful(entry.result.fetchedAt, entry.resultStoredAt, this._clock.now());
554565 if (!hasData && cacheOnly && code !== "API_KEY_REQUIRED" && code !== "API_KEY_INVALID") code = "NO_CACHED_DATA";
555566 return {
556567 hasData: hasData,
@@ -853,6 +864,9 @@ Companion.prototype._storeTraffic = function (favorite, language, data, requestI
853864
854865 Companion.prototype._acceptOutcome = function (flight, outcome) {
855866 if (flight.generation !== this._lifecycleGeneration) return false;
867+ if (outcome.status === "UNAVAILABLE" && outcome.error.code === "RATE_LIMITED") {
868+ this._metrics.rateLimitedResponses += 1;
869+ }
856870 if (outcome.status !== "UNAVAILABLE" || outcome.error.code !== "API_KEY_INVALID") return true;
857871 var requests = this._requests.slice();
858872 this._metrics.failures += 1;
@@ -1093,6 +1107,7 @@ Companion.prototype.metrics = function () {
10931107 cacheMisses: this._metrics.cacheMisses,
10941108 successes: this._metrics.successes,
10951109 failures: this._metrics.failures,
1110+ rateLimitedResponses: this._metrics.rateLimitedResponses,
10961111 totalLatencyMs: this._metrics.totalLatencyMs,
10971112 lastLatencyMs: this._metrics.lastLatencyMs
10981113 };
@@ -71,11 +71,20 @@ function normalizeIncomingPayload(payload) {
71 return aliasCount > 0 ? normalized : null;71 return aliasCount > 0 ? normalized : null;
72 }72 }
73 73
74+function cacheAge(timestamp, storedAt, now) {
75+ return now - Math.min(timestamp * 1000, storedAt);
76+}
77+
74 function cacheFresh(timestamp, storedAt, now) {78 function cacheFresh(timestamp, storedAt, now) {
75- var age = now - Math.min(timestamp * 1000, storedAt);79+ var age = cacheAge(timestamp, storedAt, now);
76 return age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000;80 return age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000;
77 }81 }
78 82
83+function cacheUseful(timestamp, storedAt, now) {
84+ var age = cacheAge(timestamp, storedAt, now);
85+ return age >= 0 && age < contracts.USEFUL_STALE_SECONDS * 1000;
86+}
87+
79 function sameFavoriteContentSet(left, right) {88 function sameFavoriteContentSet(left, right) {
80 if (left.length !== right.length) return false;89 if (left.length !== right.length) return false;
81 return left.every(function (favorite) {90 return left.every(function (favorite) {
@@ -159,6 +168,7 @@ function Companion(options) {
159 cacheMisses: 0,168 cacheMisses: 0,
160 successes: 0,169 successes: 0,
161 failures: 0,170 failures: 0,
171+ rateLimitedResponses: 0,
162 totalLatencyMs: 0,172 totalLatencyMs: 0,
163 lastLatencyMs: 0173 lastLatencyMs: 0
164 };174 };
@@ -550,7 +560,8 @@ Companion.prototype._favoriteView = function (favorite, flightErrors, aggregateE
550 if (entry !== null && entry.serviceId !== favorite.serviceId) entry = null;560 if (entry !== null && entry.serviceId !== favorite.serviceId) entry = null;
551 var code = aggregateError || (flightErrors && flightErrors[favorite.serviceId]) || null;561 var code = aggregateError || (flightErrors && flightErrors[favorite.serviceId]) || null;
552 if (!code && entry && entry.refreshError) code = entry.refreshError.code;562 if (!code && entry && entry.refreshError) code = entry.refreshError.code;
553- var hasData = entry !== null && hasOwn.call(entry, "result");563+ var hasData = entry !== null && hasOwn.call(entry, "result")
564+ && cacheUseful(entry.result.fetchedAt, entry.resultStoredAt, this._clock.now());
554 if (!hasData && cacheOnly && code !== "API_KEY_REQUIRED" && code !== "API_KEY_INVALID") code = "NO_CACHED_DATA";565 if (!hasData && cacheOnly && code !== "API_KEY_REQUIRED" && code !== "API_KEY_INVALID") code = "NO_CACHED_DATA";
555 return {566 return {
556 hasData: hasData,567 hasData: hasData,
@@ -853,6 +864,9 @@ Companion.prototype._storeTraffic = function (favorite, language, data, requestI
853 864
854 Companion.prototype._acceptOutcome = function (flight, outcome) {865 Companion.prototype._acceptOutcome = function (flight, outcome) {
855 if (flight.generation !== this._lifecycleGeneration) return false;866 if (flight.generation !== this._lifecycleGeneration) return false;
867+ if (outcome.status === "UNAVAILABLE" && outcome.error.code === "RATE_LIMITED") {
868+ this._metrics.rateLimitedResponses += 1;
869+ }
856 if (outcome.status !== "UNAVAILABLE" || outcome.error.code !== "API_KEY_INVALID") return true;870 if (outcome.status !== "UNAVAILABLE" || outcome.error.code !== "API_KEY_INVALID") return true;
857 var requests = this._requests.slice();871 var requests = this._requests.slice();
858 this._metrics.failures += 1;872 this._metrics.failures += 1;
@@ -1093,6 +1107,7 @@ Companion.prototype.metrics = function () {
1093 cacheMisses: this._metrics.cacheMisses,1107 cacheMisses: this._metrics.cacheMisses,
1094 successes: this._metrics.successes,1108 successes: this._metrics.successes,
1095 failures: this._metrics.failures,1109 failures: this._metrics.failures,
1110+ rateLimitedResponses: this._metrics.rateLimitedResponses,
1096 totalLatencyMs: this._metrics.totalLatencyMs,1111 totalLatencyMs: this._metrics.totalLatencyMs,
1097 lastLatencyMs: this._metrics.lastLatencyMs1112 lastLatencyMs: this._metrics.lastLatencyMs
1098 };1113 };
modified packages/companion/src/prim-departures.js +5 -2
@@ -127,6 +127,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
127127 var recordedAt;
128128 var matched = [];
129129 var departures = [];
130+ var visitCount = 0;
130131 var realtimeCount = 0;
131132 var departure;
132133 var interval;
@@ -155,6 +156,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
155156 for (offset = 0; offset < visits.length; offset += 1) {
156157 visit = visits[offset];
157158 if (!contracts.isObject(visit)) fail();
159+ visitCount += 1;
158160 recordedAt = epochSeconds(visit.RecordedAtTime);
159161 journey = matchedJourney(visit, routing);
160162 if (typeof journey === "undefined") continue;
@@ -162,7 +164,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
162164 insertBounded(matched, parseMatchedVisit(journey));
163165 }
164166 }
165- if (matched.length === 0) fail();
167+ if (matched.length === 0 && visitCount > 0) fail();
166168 for (index = 0; index < matched.length; index += 1) {
167169 visit = matched[index];
168170 if (visit.hasExpected) realtimeCount += 1;
@@ -185,7 +187,8 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
185187 }
186188 result = {
187189 fetchedAt: fetchedAt,
188- freshness: realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED",
190+ freshness: matched.length === 0 ? "SCHEDULED"
191+ : realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED",
189192 departures: Object.freeze(departures)
190193 };
191194 if (typeof sourceUpdatedAt !== "undefined") result.sourceUpdatedAt = sourceUpdatedAt;
@@ -127,6 +127,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
127 var recordedAt;127 var recordedAt;
128 var matched = [];128 var matched = [];
129 var departures = [];129 var departures = [];
130+ var visitCount = 0;
130 var realtimeCount = 0;131 var realtimeCount = 0;
131 var departure;132 var departure;
132 var interval;133 var interval;
@@ -155,6 +156,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
155 for (offset = 0; offset < visits.length; offset += 1) {156 for (offset = 0; offset < visits.length; offset += 1) {
156 visit = visits[offset];157 visit = visits[offset];
157 if (!contracts.isObject(visit)) fail();158 if (!contracts.isObject(visit)) fail();
159+ visitCount += 1;
158 recordedAt = epochSeconds(visit.RecordedAtTime);160 recordedAt = epochSeconds(visit.RecordedAtTime);
159 journey = matchedJourney(visit, routing);161 journey = matchedJourney(visit, routing);
160 if (typeof journey === "undefined") continue;162 if (typeof journey === "undefined") continue;
@@ -162,7 +164,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
162 insertBounded(matched, parseMatchedVisit(journey));164 insertBounded(matched, parseMatchedVisit(journey));
163 }165 }
164 }166 }
165- if (matched.length === 0) fail();167+ if (matched.length === 0 && visitCount > 0) fail();
166 for (index = 0; index < matched.length; index += 1) {168 for (index = 0; index < matched.length; index += 1) {
167 visit = matched[index];169 visit = matched[index];
168 if (visit.hasExpected) realtimeCount += 1;170 if (visit.hasExpected) realtimeCount += 1;
@@ -185,7 +187,8 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) {
185 }187 }
186 result = {188 result = {
187 fetchedAt: fetchedAt,189 fetchedAt: fetchedAt,
188- freshness: realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED",190+ freshness: matched.length === 0 ? "SCHEDULED"
191+ : realtimeCount === matched.length ? "REALTIME" : realtimeCount === 0 ? "SCHEDULED" : "MIXED",
189 departures: Object.freeze(departures)192 departures: Object.freeze(departures)
190 };193 };
191 if (typeof sourceUpdatedAt !== "undefined") result.sourceUpdatedAt = sourceUpdatedAt;194 if (typeof sourceUpdatedAt !== "undefined") result.sourceUpdatedAt = sourceUpdatedAt;
modified packages/companion/src/prim-traffic.js +12 -3
@@ -286,12 +286,17 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill
286286 checkedAt: Math.floor(checkedAtMilliseconds / 1000)
287287 };
288288 if (!contracts.uint32(result.checkedAt)) fail();
289- if (typeof line === "undefined") return result;
289+ if (typeof line === "undefined") {
290+ // The bulk endpoint lists impacted lines only. A valid catalog line omitted
291+ // from a successfully normalized response therefore has normal traffic.
292+ if (typeof lineId === "string" && exactMatch(CANONICAL_LINE, lineId) !== null) result.state = "NORMAL";
293+ return result;
294+ }
290295 for (index = 0; index < line.length; index += 1) {
291296 disruption = envelope.disruptions[line[index]];
292297 if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue;
293- priority = disruption.severity === "PERTURBEE"
294- && typeof disruption.title !== "undefined" && typeof disruption.text !== "undefined" ? 1 : 2;
298+ if (typeof disruption.title === "undefined" || typeof disruption.text === "undefined") priority = 2;
299+ else priority = disruption.severity === "BLOQUANTE" ? 3 : 1;
295300 if (typeof best === "undefined" || priority > bestPriority
296301 || (priority === bestPriority && disruption.id < best.id)) {
297302 best = disruption;
@@ -303,6 +308,10 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill
303308 result.state = "DELAYED";
304309 result.title = best.title;
305310 result.text = best.text;
311+ } else if (bestPriority === 3) {
312+ result.state = "STOPPED";
313+ result.title = best.title;
314+ result.text = best.text;
306315 }
307316 return result;
308317 }
@@ -286,12 +286,17 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill
286 checkedAt: Math.floor(checkedAtMilliseconds / 1000)286 checkedAt: Math.floor(checkedAtMilliseconds / 1000)
287 };287 };
288 if (!contracts.uint32(result.checkedAt)) fail();288 if (!contracts.uint32(result.checkedAt)) fail();
289- if (typeof line === "undefined") return result;289+ if (typeof line === "undefined") {
290+ // The bulk endpoint lists impacted lines only. A valid catalog line omitted
291+ // from a successfully normalized response therefore has normal traffic.
292+ if (typeof lineId === "string" && exactMatch(CANONICAL_LINE, lineId) !== null) result.state = "NORMAL";
293+ return result;
294+ }
290 for (index = 0; index < line.length; index += 1) {295 for (index = 0; index < line.length; index += 1) {
291 disruption = envelope.disruptions[line[index]];296 disruption = envelope.disruptions[line[index]];
292 if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue;297 if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue;
293- priority = disruption.severity === "PERTURBEE"298+ if (typeof disruption.title === "undefined" || typeof disruption.text === "undefined") priority = 2;
294- && typeof disruption.title !== "undefined" && typeof disruption.text !== "undefined" ? 1 : 2;299+ else priority = disruption.severity === "BLOQUANTE" ? 3 : 1;
295 if (typeof best === "undefined" || priority > bestPriority300 if (typeof best === "undefined" || priority > bestPriority
296 || (priority === bestPriority && disruption.id < best.id)) {301 || (priority === bestPriority && disruption.id < best.id)) {
297 best = disruption;302 best = disruption;
@@ -303,6 +308,10 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill
303 result.state = "DELAYED";308 result.state = "DELAYED";
304 result.title = best.title;309 result.title = best.title;
305 result.text = best.text;310 result.text = best.text;
311+ } else if (bestPriority === 3) {
312+ result.state = "STOPPED";
313+ result.title = best.title;
314+ result.text = best.text;
306 }315 }
307 return result;316 return result;
308 }317 }
modified packages/companion/test/companion.test.js +122 -0
@@ -199,6 +199,21 @@ function closeWith(target, action, favorites, value, forceFull) {
199199 if (forceFull) update.forceFullSync = true;
200200 target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) });
201201 }
202+
203+test("Core Android decoded close responses preserve favorites with line colors", function () {
204+ var target = harness({ storage: configuredStorage([]) });
205+ var update = {
206+ schemaVersion: 1,
207+ favorites: [Object.assign({}, FIRST, { sortOrder: 0 })],
208+ apiKeyUpdate: { schemaVersion: 1, action: "KEEP" }
209+ };
210+
211+ target.Pebble.emit("webviewclosed", { response: JSON.stringify(update) });
212+
213+ assert.deepEqual(configuration.loadConfiguration(target.storage).favorites, update.favorites);
214+ target.companion.stop();
215+});
216+
202217 function serviceRow(favorite) {
203218 return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel,
204219 destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode,
@@ -343,6 +358,95 @@ test("overview and detail CACHE_ONLY finish misses and stale hits without PRIM o
343358 target.companion.stop();
344359 });
345360
361+test("complete cached results are stale only before the fifteen-minute useful boundary", function () {
362+ var boundary = contracts.USEFUL_STALE_SECONDS * 1000;
363+ [
364+ { age: boundary - 1, hasData: true, error: 0 },
365+ { age: boundary, hasData: false, error: 6 },
366+ { age: boundary + 1, hasData: false, error: 6 }
367+ ].forEach(function (expected) {
368+ var clock = new fakes.FakeClock();
369+ var storage = configuredStorage();
370+ seedOverview(storage, [FIRST], clock);
371+ clock.advance(expected.age);
372+ var target = harness({ storage: storage, clock: clock });
373+ ready(target);
374+ sendDetail(target, "cached", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY);
375+ drain(target);
376+ var cached = rows(target, "cached")[0];
377+ assert.equal(cached.hasData, expected.hasData);
378+ assert.equal(cached.stale, true);
379+ assert.equal(cached.error, expected.error);
380+ assert.equal(requests(target, "departures").length, 0);
381+ assert.equal(requests(target, "traffic").length, 0);
382+ });
383+});
384+
385+test("429 is terminal for one event, preserves useful stale data, and increments only a safe counter", function () {
386+ var clock = new fakes.FakeClock();
387+ var storage = configuredStorage();
388+ seedOverview(storage, [FIRST], clock);
389+ clock.advance(contracts.CACHE_FRESH_SECONDS * 1000);
390+ var target = harness({ storage: storage, clock: clock });
391+ ready(target);
392+ sendDetail(target, "quota", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT);
393+ var departure = requests(target, "departures")[0];
394+ departure.respond(429, "", { "Retry-After": "17" });
395+ target.clock.advance(0);
396+ resolveProduction(target, [FIRST]);
397+ drain(target);
398+ var quota = rows(target, "quota").at(-1);
399+ assert.equal(quota.hasData, true);
400+ assert.equal(quota.stale, true);
401+ assert.equal(quota.error, 5);
402+ assert.equal(target.companion.metrics().rateLimitedResponses, 1);
403+ assert.doesNotMatch(JSON.stringify(target.companion.metrics()), /test-personal|favorite|IDFM|apikey|Authorization/);
404+ target.clock.advance(contracts.LIMITS.httpTimeoutMs * 2);
405+ assert.equal(requests(target, "departures").length, 1);
406+ assert.equal(requests(target, "traffic").length, 1);
407+});
408+
409+test("the failure table deterministically projects unconfigured, stale, or unavailable", function () {
410+ [
411+ { name: "missing key", keyStatus: contracts.KEY_STATUS.MISSING, error: 1, state: "UNCONFIGURED" },
412+ { name: "invalid key", keyStatus: contracts.KEY_STATUS.INVALID, error: 3, state: "UNCONFIGURED" },
413+ { name: "revoked key", status: 401, error: 3, state: "UNCONFIGURED" },
414+ { name: "disconnect", status: 0, error: 7, state: "STALE" },
415+ { name: "source failure", status: 503, error: 7, state: "STALE" },
416+ { name: "rate limit", status: 429, error: 5, state: "STALE" },
417+ { name: "malformed response", status: 200, body: {}, error: 7, state: "STALE" },
418+ { name: "partial response", status: 200, body: { Siri: {} }, error: 7, state: "STALE" },
419+ { name: "timeout without cache", timeout: true, noCache: true, error: 7, state: "UNAVAILABLE" }
420+ ].forEach(function (failure) {
421+ var clock = new fakes.FakeClock();
422+ var storage;
423+ if (failure.keyStatus === contracts.KEY_STATUS.MISSING) {
424+ storage = new fakes.FakeStorage();
425+ assert.equal(configuration.saveConfiguration(storage, {
426+ schemaVersion: 1, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING
427+ }), true);
428+ } else storage = configuredStorage([FIRST], failure.keyStatus);
429+ if (!failure.noCache) seedOverview(storage, [FIRST], clock);
430+ clock.advance(contracts.CACHE_FRESH_SECONDS * 1000);
431+ var target = harness({ storage: storage, clock: clock });
432+ ready(target);
433+ sendDetail(target, failure.name, FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT);
434+ if (typeof failure.status === "number" || failure.timeout) {
435+ respond(target, requests(target, "traffic")[0], 200, trafficBody([FIRST]));
436+ if (failure.timeout) target.clock.advance(contracts.LIMITS.httpTimeoutMs);
437+ else respond(target, requests(target, "departures")[0], failure.status,
438+ typeof failure.body === "undefined" ? "" : failure.body);
439+ }
440+ drain(target);
441+ var record = rows(target, failure.name).at(-1);
442+ var state = record.error === 1 || record.error === 3
443+ ? "UNCONFIGURED" : record.hasData ? "STALE" : "UNAVAILABLE";
444+ assert.equal(record.error, failure.error, failure.name);
445+ assert.equal(state, failure.state, failure.name);
446+ target.companion.stop();
447+ });
448+});
449+
346450 test("an opening overview completes after cache-only detail entry and local Back", function () {
347451 var target = harness(); ready(target); sendOverview(target, "opening-overview");
348452 sendDetail(target, "empty-detail", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY);
@@ -672,6 +776,24 @@ test("credential replacement ignores saved callbacks from the canceled lifecycle
672776 target.companion.stop();
673777 });
674778
779+test("credential removal erases the key, retains favorites, and blocks PRIM until replacement", function () {
780+ var storage = configuredStorage([FIRST, SECOND]);
781+ var target = harness({ storage: storage });
782+ ready(target);
783+ closeWith(target, "REMOVE", [FIRST, SECOND]);
784+ acknowledgeConfiguration(target, 0);
785+ var saved = configuration.loadConfiguration(storage);
786+ assert.equal(saved.primApiKey, null);
787+ assert.equal(saved.keyStatus, contracts.KEY_STATUS.MISSING);
788+ assert.deepEqual(saved.favorites, [FIRST, SECOND]);
789+ assert.doesNotMatch(JSON.stringify(storage.values), new RegExp(TEST_KEY));
790+ sendDetail(target, "removed-key", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT);
791+ assert.equal(rows(target, "removed-key")[0].error, 1);
792+ assert.equal(requests(target, "departures").length, 0);
793+ assert.equal(requests(target, "traffic").length, 0);
794+ target.companion.stop();
795+});
796+
675797 test("interrupted same-key replacement never clears durable invalid-key authority", function () {
676798 ["marker-removal", "configured-write"].forEach(function (failure) {
677799 var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem;
@@ -199,6 +199,21 @@ function closeWith(target, action, favorites, value, forceFull) {
199 if (forceFull) update.forceFullSync = true;199 if (forceFull) update.forceFullSync = true;
200 target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) });200 target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) });
201 }201 }
202+
203+test("Core Android decoded close responses preserve favorites with line colors", function () {
204+ var target = harness({ storage: configuredStorage([]) });
205+ var update = {
206+ schemaVersion: 1,
207+ favorites: [Object.assign({}, FIRST, { sortOrder: 0 })],
208+ apiKeyUpdate: { schemaVersion: 1, action: "KEEP" }
209+ };
210+
211+ target.Pebble.emit("webviewclosed", { response: JSON.stringify(update) });
212+
213+ assert.deepEqual(configuration.loadConfiguration(target.storage).favorites, update.favorites);
214+ target.companion.stop();
215+});
216+
202 function serviceRow(favorite) {217 function serviceRow(favorite) {
203 return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel,218 return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel,
204 destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode,219 destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode,
@@ -343,6 +358,95 @@ test("overview and detail CACHE_ONLY finish misses and stale hits without PRIM o
343 target.companion.stop();358 target.companion.stop();
344 });359 });
345 360
361+test("complete cached results are stale only before the fifteen-minute useful boundary", function () {
362+ var boundary = contracts.USEFUL_STALE_SECONDS * 1000;
363+ [
364+ { age: boundary - 1, hasData: true, error: 0 },
365+ { age: boundary, hasData: false, error: 6 },
366+ { age: boundary + 1, hasData: false, error: 6 }
367+ ].forEach(function (expected) {
368+ var clock = new fakes.FakeClock();
369+ var storage = configuredStorage();
370+ seedOverview(storage, [FIRST], clock);
371+ clock.advance(expected.age);
372+ var target = harness({ storage: storage, clock: clock });
373+ ready(target);
374+ sendDetail(target, "cached", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY);
375+ drain(target);
376+ var cached = rows(target, "cached")[0];
377+ assert.equal(cached.hasData, expected.hasData);
378+ assert.equal(cached.stale, true);
379+ assert.equal(cached.error, expected.error);
380+ assert.equal(requests(target, "departures").length, 0);
381+ assert.equal(requests(target, "traffic").length, 0);
382+ });
383+});
384+
385+test("429 is terminal for one event, preserves useful stale data, and increments only a safe counter", function () {
386+ var clock = new fakes.FakeClock();
387+ var storage = configuredStorage();
388+ seedOverview(storage, [FIRST], clock);
389+ clock.advance(contracts.CACHE_FRESH_SECONDS * 1000);
390+ var target = harness({ storage: storage, clock: clock });
391+ ready(target);
392+ sendDetail(target, "quota", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT);
393+ var departure = requests(target, "departures")[0];
394+ departure.respond(429, "", { "Retry-After": "17" });
395+ target.clock.advance(0);
396+ resolveProduction(target, [FIRST]);
397+ drain(target);
398+ var quota = rows(target, "quota").at(-1);
399+ assert.equal(quota.hasData, true);
400+ assert.equal(quota.stale, true);
401+ assert.equal(quota.error, 5);
402+ assert.equal(target.companion.metrics().rateLimitedResponses, 1);
403+ assert.doesNotMatch(JSON.stringify(target.companion.metrics()), /test-personal|favorite|IDFM|apikey|Authorization/);
404+ target.clock.advance(contracts.LIMITS.httpTimeoutMs * 2);
405+ assert.equal(requests(target, "departures").length, 1);
406+ assert.equal(requests(target, "traffic").length, 1);
407+});
408+
409+test("the failure table deterministically projects unconfigured, stale, or unavailable", function () {
410+ [
411+ { name: "missing key", keyStatus: contracts.KEY_STATUS.MISSING, error: 1, state: "UNCONFIGURED" },
412+ { name: "invalid key", keyStatus: contracts.KEY_STATUS.INVALID, error: 3, state: "UNCONFIGURED" },
413+ { name: "revoked key", status: 401, error: 3, state: "UNCONFIGURED" },
414+ { name: "disconnect", status: 0, error: 7, state: "STALE" },
415+ { name: "source failure", status: 503, error: 7, state: "STALE" },
416+ { name: "rate limit", status: 429, error: 5, state: "STALE" },
417+ { name: "malformed response", status: 200, body: {}, error: 7, state: "STALE" },
418+ { name: "partial response", status: 200, body: { Siri: {} }, error: 7, state: "STALE" },
419+ { name: "timeout without cache", timeout: true, noCache: true, error: 7, state: "UNAVAILABLE" }
420+ ].forEach(function (failure) {
421+ var clock = new fakes.FakeClock();
422+ var storage;
423+ if (failure.keyStatus === contracts.KEY_STATUS.MISSING) {
424+ storage = new fakes.FakeStorage();
425+ assert.equal(configuration.saveConfiguration(storage, {
426+ schemaVersion: 1, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING
427+ }), true);
428+ } else storage = configuredStorage([FIRST], failure.keyStatus);
429+ if (!failure.noCache) seedOverview(storage, [FIRST], clock);
430+ clock.advance(contracts.CACHE_FRESH_SECONDS * 1000);
431+ var target = harness({ storage: storage, clock: clock });
432+ ready(target);
433+ sendDetail(target, failure.name, FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT);
434+ if (typeof failure.status === "number" || failure.timeout) {
435+ respond(target, requests(target, "traffic")[0], 200, trafficBody([FIRST]));
436+ if (failure.timeout) target.clock.advance(contracts.LIMITS.httpTimeoutMs);
437+ else respond(target, requests(target, "departures")[0], failure.status,
438+ typeof failure.body === "undefined" ? "" : failure.body);
439+ }
440+ drain(target);
441+ var record = rows(target, failure.name).at(-1);
442+ var state = record.error === 1 || record.error === 3
443+ ? "UNCONFIGURED" : record.hasData ? "STALE" : "UNAVAILABLE";
444+ assert.equal(record.error, failure.error, failure.name);
445+ assert.equal(state, failure.state, failure.name);
446+ target.companion.stop();
447+ });
448+});
449+
346 test("an opening overview completes after cache-only detail entry and local Back", function () {450 test("an opening overview completes after cache-only detail entry and local Back", function () {
347 var target = harness(); ready(target); sendOverview(target, "opening-overview");451 var target = harness(); ready(target); sendOverview(target, "opening-overview");
348 sendDetail(target, "empty-detail", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY);452 sendDetail(target, "empty-detail", FIRST, contracts.REQUEST_TRIGGER.CACHE_ONLY);
@@ -672,6 +776,24 @@ test("credential replacement ignores saved callbacks from the canceled lifecycle
672 target.companion.stop();776 target.companion.stop();
673 });777 });
674 778
779+test("credential removal erases the key, retains favorites, and blocks PRIM until replacement", function () {
780+ var storage = configuredStorage([FIRST, SECOND]);
781+ var target = harness({ storage: storage });
782+ ready(target);
783+ closeWith(target, "REMOVE", [FIRST, SECOND]);
784+ acknowledgeConfiguration(target, 0);
785+ var saved = configuration.loadConfiguration(storage);
786+ assert.equal(saved.primApiKey, null);
787+ assert.equal(saved.keyStatus, contracts.KEY_STATUS.MISSING);
788+ assert.deepEqual(saved.favorites, [FIRST, SECOND]);
789+ assert.doesNotMatch(JSON.stringify(storage.values), new RegExp(TEST_KEY));
790+ sendDetail(target, "removed-key", FIRST, contracts.REQUEST_TRIGGER.MANUAL_SELECT);
791+ assert.equal(rows(target, "removed-key")[0].error, 1);
792+ assert.equal(requests(target, "departures").length, 0);
793+ assert.equal(requests(target, "traffic").length, 0);
794+ target.companion.stop();
795+});
796+
675 test("interrupted same-key replacement never clears durable invalid-key authority", function () {797 test("interrupted same-key replacement never clears durable invalid-key authority", function () {
676 ["marker-removal", "configured-write"].forEach(function (failure) {798 ["marker-removal", "configured-write"].forEach(function (failure) {
677 var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem;799 var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem;
modified packages/companion/test/prim-client.test.js +4 -2
@@ -411,8 +411,10 @@ test("transport errors and malformed traffic are unavailable, not successful UNK
411411 h.transport.instances[1].respond(503, {});
412412 h.clock.advance(0);
413413 assertError(result.values[1], "SOURCE_UNAVAILABLE", TRAFFIC_MS);
414- requestTraffic(h, "IDFM:C300", result);
415- h.transport.instances[2].respond(200, trafficFixture());
414+ requestTraffic(h, "IDFM:C400", result);
415+ var uncertain = trafficFixture();
416+ delete uncertain.disruptions[3].message;
417+ h.transport.instances[2].respond(200, uncertain);
416418 h.clock.advance(0);
417419 assert.deepEqual(result.values[2], { status: "AVAILABLE", data: { schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000 } });
418420 });
@@ -411,8 +411,10 @@ test("transport errors and malformed traffic are unavailable, not successful UNK
411 h.transport.instances[1].respond(503, {});411 h.transport.instances[1].respond(503, {});
412 h.clock.advance(0);412 h.clock.advance(0);
413 assertError(result.values[1], "SOURCE_UNAVAILABLE", TRAFFIC_MS);413 assertError(result.values[1], "SOURCE_UNAVAILABLE", TRAFFIC_MS);
414- requestTraffic(h, "IDFM:C300", result);414+ requestTraffic(h, "IDFM:C400", result);
415- h.transport.instances[2].respond(200, trafficFixture());415+ var uncertain = trafficFixture();
416+ delete uncertain.disruptions[3].message;
417+ h.transport.instances[2].respond(200, uncertain);
416 h.clock.advance(0);418 h.clock.advance(0);
417 assert.deepEqual(result.values[2], { status: "AVAILABLE", data: { schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000 } });419 assert.deepEqual(result.values[2], { status: "AVAILABLE", data: { schemaVersion: 1, state: "UNKNOWN", checkedAt: TRAFFIC_MS / 1000 } });
418 });420 });
modified packages/companion/test/prim-normalization.test.js +29 -7
@@ -124,6 +124,13 @@ test("delays, cancelled visits and scheduled-only visits retain their distinct s
124124 ]));
125125 });
126126
127+test("a valid response without visits reports no scheduled departures", function () {
128+ var payload = fixture("metro");
129+ payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit = [];
130+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, routing(2001), FETCHED_AT),
131+ snapshot("SCHEDULED", []));
132+});
133+
127134 test("foreign stop, line and destination visits do not contaminate departures or source freshness", function () {
128135 var payload = fixture("partial");
129136 var list = visits(payload);
@@ -223,8 +230,8 @@ test("upstream routing may exceed watch ID lengths without leaking into the snap
223230 test("traffic distinguishes observed normal, useful disruption and source uncertainty", function () {
224231 var payload = trafficFixture();
225232 var expected = {
226- C100: "NORMAL", C200: "DELAYED", C300: "UNKNOWN", C400: "UNKNOWN",
227- C500: "NORMAL", C600: "NORMAL", C999: "UNKNOWN"
233+ C100: "NORMAL", C200: "DELAYED", C300: "STOPPED", C400: "STOPPED",
234+ C500: "NORMAL", C600: "NORMAL", C999: "NORMAL"
228235 };
229236 Object.keys(expected).forEach(function (line) {
230237 var result = trafficDetail(payload, "IDFM:" + line);
@@ -241,13 +248,28 @@ test("traffic distinguishes observed normal, useful disruption and source uncert
241248 });
242249 });
243250
244-test("BLOQUANTE never invents STOPPED and outranks usable PERTURBEE details", function () {
251+test("lines omitted from the disruption-only bulk feed have normal traffic", function () {
245252 var payload = trafficFixture();
246- payload.disruptions[2].severity.effect = "NO_SERVICE";
247- payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "stopped-active"];
248- assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN");
253+ payload.lines = payload.lines.filter(function (line) { return line.id !== "line:IDFM:C100"; });
254+ assert.equal(trafficDetail(payload, "IDFM:C100").state, "NORMAL");
255+ assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN");
256+});
257+
258+test("BLOQUANTE produces STOPPED and outranks usable PERTURBEE details", function () {
259+ var payload = trafficFixture();
260+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];
261+ assert.deepEqual(trafficDetail(payload, "IDFM:C200"), {
262+ schemaVersion: 1, state: "STOPPED", checkedAt: TRAFFIC_MS / 1000,
263+ title: "Tram T3 : incident", text: "Une perturbation est en cours."
264+ });
249265 payload.lines[1].impactedObjects[0].disruptionIds.reverse();
250- assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN");
266+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "STOPPED");
267+});
268+
269+test("an active disruption without complete details remains UNKNOWN", function () {
270+ var payload = trafficFixture();
271+ delete payload.disruptions[3].message;
272+ assert.equal(trafficDetail(payload, "IDFM:C400").state, "UNKNOWN");
251273 });
252274
253275 test("Paris periods are inclusive at start and exclusive at end, including summer time", function () {
@@ -124,6 +124,13 @@ test("delays, cancelled visits and scheduled-only visits retain their distinct s
124 ]));124 ]));
125 });125 });
126 126
127+test("a valid response without visits reports no scheduled departures", function () {
128+ var payload = fixture("metro");
129+ payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit = [];
130+ assert.deepEqual(departures.normalizePrimDepartureResponse(payload, routing(2001), FETCHED_AT),
131+ snapshot("SCHEDULED", []));
132+});
133+
127 test("foreign stop, line and destination visits do not contaminate departures or source freshness", function () {134 test("foreign stop, line and destination visits do not contaminate departures or source freshness", function () {
128 var payload = fixture("partial");135 var payload = fixture("partial");
129 var list = visits(payload);136 var list = visits(payload);
@@ -223,8 +230,8 @@ test("upstream routing may exceed watch ID lengths without leaking into the snap
223 test("traffic distinguishes observed normal, useful disruption and source uncertainty", function () {230 test("traffic distinguishes observed normal, useful disruption and source uncertainty", function () {
224 var payload = trafficFixture();231 var payload = trafficFixture();
225 var expected = {232 var expected = {
226- C100: "NORMAL", C200: "DELAYED", C300: "UNKNOWN", C400: "UNKNOWN",233+ C100: "NORMAL", C200: "DELAYED", C300: "STOPPED", C400: "STOPPED",
227- C500: "NORMAL", C600: "NORMAL", C999: "UNKNOWN"234+ C500: "NORMAL", C600: "NORMAL", C999: "NORMAL"
228 };235 };
229 Object.keys(expected).forEach(function (line) {236 Object.keys(expected).forEach(function (line) {
230 var result = trafficDetail(payload, "IDFM:" + line);237 var result = trafficDetail(payload, "IDFM:" + line);
@@ -241,13 +248,28 @@ test("traffic distinguishes observed normal, useful disruption and source uncert
241 });248 });
242 });249 });
243 250
244-test("BLOQUANTE never invents STOPPED and outranks usable PERTURBEE details", function () {251+test("lines omitted from the disruption-only bulk feed have normal traffic", function () {
245 var payload = trafficFixture();252 var payload = trafficFixture();
246- payload.disruptions[2].severity.effect = "NO_SERVICE";253+ payload.lines = payload.lines.filter(function (line) { return line.id !== "line:IDFM:C100"; });
247- payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "stopped-active"];254+ assert.equal(trafficDetail(payload, "IDFM:C100").state, "NORMAL");
248- assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN");255+ assert.equal(trafficDetail(payload, "invalid-line").state, "UNKNOWN");
256+});
257+
258+test("BLOQUANTE produces STOPPED and outranks usable PERTURBEE details", function () {
259+ var payload = trafficFixture();
260+ payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "blocking-unclassified"];
261+ assert.deepEqual(trafficDetail(payload, "IDFM:C200"), {
262+ schemaVersion: 1, state: "STOPPED", checkedAt: TRAFFIC_MS / 1000,
263+ title: "Tram T3 : incident", text: "Une perturbation est en cours."
264+ });
249 payload.lines[1].impactedObjects[0].disruptionIds.reverse();265 payload.lines[1].impactedObjects[0].disruptionIds.reverse();
250- assert.equal(trafficDetail(payload, "IDFM:C200").state, "UNKNOWN");266+ assert.equal(trafficDetail(payload, "IDFM:C200").state, "STOPPED");
267+});
268+
269+test("an active disruption without complete details remains UNKNOWN", function () {
270+ var payload = trafficFixture();
271+ delete payload.disruptions[3].message;
272+ assert.equal(trafficDetail(payload, "IDFM:C400").state, "UNKNOWN");
251 });273 });
252 274
253 test("Paris periods are inclusive at start and exclusive at end, including summer time", function () {275 test("Paris periods are inclusive at start and exclusive at end, including summer time", function () {
modified packages/config-page/index.html +6 -6
@@ -152,13 +152,13 @@
152152 <p><strong>Publication director:</strong> Anthodev.</p>
153153 <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
154154 <h3>Hosting</h3>
155- <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p>
155+ <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovenia. Electronic support: <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">bunny.net Support</a>.</p>
156156 <h3>Privacy and data flow</h3>
157157 <p>This app page sets no cookies and uses no analytics or telemetry.</p>
158- <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
158+ <p>Your saved personal PRIM key stays in your phone’s localStorage. On a permitted refresh event, the phone sends it directly to PRIM over HTTPS; Lapin Futé has no hosted backend and the key never reaches this configuration host.</p>
159159 <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>
160160 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
161- <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
161+ <p>bunny.net hosts this page and its static catalog. BunnyWay may process connection data and requested URLs under its <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">Privacy &amp; Data Policy</a>.</p>
162162 <h3>Data and credits</h3>
163163 <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p>
164164 <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p>
@@ -174,13 +174,13 @@
174174 <p><strong>Directeur de la publication :</strong> Anthodev.</p>
175175 <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
176176 <h3>Hébergement</h3>
177- <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p>
177+ <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovénie. Assistance électronique : <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">support bunny.net</a>.</p>
178178 <h3>Vie privée et circulation des données</h3>
179179 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
180- <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
180+ <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’un événement de rafraîchissement autorisé, le téléphone l’envoie directement à PRIM en HTTPS ; Lapin Futé n’a aucun serveur applicatif hébergé et la clé n’atteint jamais cet hébergeur de configuration.</p>
181181 <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>
182182 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
183- <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
183+ <p>Cette page et son catalogue statique sont hébergés par bunny.net. BunnyWay peut traiter les données de connexion et les URL demandées selon sa <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">politique de confidentialité et de traitement des données</a>.</p>
184184 <h3>Données et crédits</h3>
185185 <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p>
186186 <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p>
@@ -152,13 +152,13 @@
152 <p><strong>Publication director:</strong> Anthodev.</p>152 <p><strong>Publication director:</strong> Anthodev.</p>
153 <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>153 <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
154 <h3>Hosting</h3>154 <h3>Hosting</h3>
155- <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p>155+ <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovenia. Electronic support: <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">bunny.net Support</a>.</p>
156 <h3>Privacy and data flow</h3>156 <h3>Privacy and data flow</h3>
157 <p>This app page sets no cookies and uses no analytics or telemetry.</p>157 <p>This app page sets no cookies and uses no analytics or telemetry.</p>
158- <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>158+ <p>Your saved personal PRIM key stays in your phone’s localStorage. On a permitted refresh event, the phone sends it directly to PRIM over HTTPS; Lapin Futé has no hosted backend and the key never reaches this configuration host.</p>
159 <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>159 <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>
160 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>160 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
161- <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>161+ <p>bunny.net hosts this page and its static catalog. BunnyWay may process connection data and requested URLs under its <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">Privacy &amp; Data Policy</a>.</p>
162 <h3>Data and credits</h3>162 <h3>Data and credits</h3>
163 <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p>163 <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p>
164 <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p>164 <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p>
@@ -174,13 +174,13 @@
174 <p><strong>Directeur de la publication :</strong> Anthodev.</p>174 <p><strong>Directeur de la publication :</strong> Anthodev.</p>
175 <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>175 <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
176 <h3>Hébergement</h3>176 <h3>Hébergement</h3>
177- <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p>177+ <p>BunnyWay d.o.o., Dunajska cesta 165, 1000 Ljubljana, Slovénie. Assistance électronique : <a href="https://support.bunny.net/" target="_blank" rel="noopener noreferrer">support bunny.net</a>.</p>
178 <h3>Vie privée et circulation des données</h3>178 <h3>Vie privée et circulation des données</h3>
179 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>179 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
180- <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>180+ <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’un événement de rafraîchissement autorisé, le téléphone l’envoie directement à PRIM en HTTPS ; Lapin Futé n’a aucun serveur applicatif hébergé et la clé n’atteint jamais cet hébergeur de configuration.</p>
181 <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>181 <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>
182 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>182 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
183- <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>183+ <p>Cette page et son catalogue statique sont hébergés par bunny.net. BunnyWay peut traiter les données de connexion et les URL demandées selon sa <a href="https://bunny.net/privacy/" target="_blank" rel="noopener noreferrer">politique de confidentialité et de traitement des données</a>.</p>
184 <h3>Données et crédits</h3>184 <h3>Données et crédits</h3>
185 <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p>185 <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p>
186 <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p>186 <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p>
modified packages/config-page/src/catalog-client.js +8 -3
@@ -4,7 +4,11 @@ import {
44 isPlaceSearchItem,
55 isServiceOption,
66 } from "./config-core.js";
7-import { catalogSearchBucket, normalizeCatalogSearchText } from "./search-text.js";
7+import {
8+ catalogSearchBucket,
9+ normalizeCatalogSearchQuery,
10+ normalizeCatalogSearchText,
11+} from "./search-text.js";
812
913 export const SEARCH_DEBOUNCE_MS = 300;
1014
@@ -12,6 +16,7 @@ const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u;
1216 const SERVICE_ID = /^svc_[A-Za-z0-9_-]{43}$/u;
1317 const REVISION = /^[a-f0-9]{64}$/u;
1418 const MANIFEST_FIELDS = ["schemaVersion", "revision", "sourceRevision", "createdAt", "attribution"];
19+const MODE_RANK = Object.freeze({ METRO: 0, RER: 1, TRANSILIEN: 2, TRAM: 3, BUS: 4 });
1520
1621 export class CatalogClientError extends Error {
1722 constructor(code) {
@@ -173,7 +178,7 @@ export function createCatalogClient({
173178 const candidate = {
174179 place,
175180 rank: searchText === normalized || stop === normalized ? 0 : searchText.startsWith(normalized) ? 1 : 2,
176- order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), place.mode, place.placeId],
181+ order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), MODE_RANK[place.mode], place.placeId],
177182 };
178183 const duplicate = best.findIndex((match) => match.place.placeId === place.placeId);
179184 if (duplicate !== -1) {
@@ -196,7 +201,7 @@ export function createCatalogClient({
196201 const length = Array.from(trimmed).length;
197202 if (length < LIMITS.catalogQueryMinCharacters) return Promise.resolve([]);
198203 if (length > LIMITS.catalogQueryMaxCharacters) return Promise.reject(new CatalogClientError("INVALID_QUERY"));
199- const normalized = normalizeCatalogSearchText(trimmed);
204+ const normalized = normalizeCatalogSearchQuery(trimmed);
200205 if (normalized === "") return Promise.reject(new CatalogClientError("INVALID_QUERY"));
201206 const controller = new AbortController();
202207 searchController = controller;
@@ -4,7 +4,11 @@ import {
4 isPlaceSearchItem,4 isPlaceSearchItem,
5 isServiceOption,5 isServiceOption,
6 } from "./config-core.js";6 } from "./config-core.js";
7-import { catalogSearchBucket, normalizeCatalogSearchText } from "./search-text.js";7+import {
8+ catalogSearchBucket,
9+ normalizeCatalogSearchQuery,
10+ normalizeCatalogSearchText,
11+} from "./search-text.js";
8 12
9 export const SEARCH_DEBOUNCE_MS = 300;13 export const SEARCH_DEBOUNCE_MS = 300;
10 14
@@ -12,6 +16,7 @@ const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u;
12 const SERVICE_ID = /^svc_[A-Za-z0-9_-]{43}$/u;16 const SERVICE_ID = /^svc_[A-Za-z0-9_-]{43}$/u;
13 const REVISION = /^[a-f0-9]{64}$/u;17 const REVISION = /^[a-f0-9]{64}$/u;
14 const MANIFEST_FIELDS = ["schemaVersion", "revision", "sourceRevision", "createdAt", "attribution"];18 const MANIFEST_FIELDS = ["schemaVersion", "revision", "sourceRevision", "createdAt", "attribution"];
19+const MODE_RANK = Object.freeze({ METRO: 0, RER: 1, TRANSILIEN: 2, TRAM: 3, BUS: 4 });
15 20
16 export class CatalogClientError extends Error {21 export class CatalogClientError extends Error {
17 constructor(code) {22 constructor(code) {
@@ -173,7 +178,7 @@ export function createCatalogClient({
173 const candidate = {178 const candidate = {
174 place,179 place,
175 rank: searchText === normalized || stop === normalized ? 0 : searchText.startsWith(normalized) ? 1 : 2,180 rank: searchText === normalized || stop === normalized ? 0 : searchText.startsWith(normalized) ? 1 : 2,
176- order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), place.mode, place.placeId],181+ order: [stop, normalizeCatalogSearchText(place.localityLabel ?? ""), MODE_RANK[place.mode], place.placeId],
177 };182 };
178 const duplicate = best.findIndex((match) => match.place.placeId === place.placeId);183 const duplicate = best.findIndex((match) => match.place.placeId === place.placeId);
179 if (duplicate !== -1) {184 if (duplicate !== -1) {
@@ -196,7 +201,7 @@ export function createCatalogClient({
196 const length = Array.from(trimmed).length;201 const length = Array.from(trimmed).length;
197 if (length < LIMITS.catalogQueryMinCharacters) return Promise.resolve([]);202 if (length < LIMITS.catalogQueryMinCharacters) return Promise.resolve([]);
198 if (length > LIMITS.catalogQueryMaxCharacters) return Promise.reject(new CatalogClientError("INVALID_QUERY"));203 if (length > LIMITS.catalogQueryMaxCharacters) return Promise.reject(new CatalogClientError("INVALID_QUERY"));
199- const normalized = normalizeCatalogSearchText(trimmed);204+ const normalized = normalizeCatalogSearchQuery(trimmed);
200 if (normalized === "") return Promise.reject(new CatalogClientError("INVALID_QUERY"));205 if (normalized === "") return Promise.reject(new CatalogClientError("INVALID_QUERY"));
201 const controller = new AbortController();206 const controller = new AbortController();
202 searchController = controller;207 searchController = controller;
modified packages/config-page/src/search-text.js +8 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/styles/config-page.css +2 -2
@@ -238,7 +238,7 @@ button:disabled { cursor: default; opacity: 0.35; }
238238 .choice-list { gap: 0; margin-top: 0.5rem; }
239239 .choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; }
240240 .choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); }
241-.choice-labels { display: grid; gap: 0.25rem; min-width: 0; }
241+.choice-labels { display: grid; flex: 1 1 auto; gap: 0.25rem; min-width: 0; }
242242 .choice-labels strong { font-size: 0.875rem; font-weight: 700; }
243243 .choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; }
244244 /* Search rows repeat the official badges as a compact wrapping strip; only
@@ -249,7 +249,7 @@ button:disabled { cursor: default; opacity: 0.35; }
249249 * Official raster pictograms with black lettering sit on a small white plate
250250 * to stay legible on the dark surface; fallback SVGs keep their official
251251 * colors and get no plate. */
252-.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
252+.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; width: 100%; min-width: 0; }
253253 .choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; }
254254 .choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; }
255255 .choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; }
@@ -238,7 +238,7 @@ button:disabled { cursor: default; opacity: 0.35; }
238 .choice-list { gap: 0; margin-top: 0.5rem; }238 .choice-list { gap: 0; margin-top: 0.5rem; }
239 .choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; }239 .choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; }
240 .choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); }240 .choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); }
241-.choice-labels { display: grid; gap: 0.25rem; min-width: 0; }241+.choice-labels { display: grid; flex: 1 1 auto; gap: 0.25rem; min-width: 0; }
242 .choice-labels strong { font-size: 0.875rem; font-weight: 700; }242 .choice-labels strong { font-size: 0.875rem; font-weight: 700; }
243 .choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; }243 .choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; }
244 /* Search rows repeat the official badges as a compact wrapping strip; only244 /* Search rows repeat the official badges as a compact wrapping strip; only
@@ -249,7 +249,7 @@ button:disabled { cursor: default; opacity: 0.35; }
249 * Official raster pictograms with black lettering sit on a small white plate249 * Official raster pictograms with black lettering sit on a small white plate
250 * to stay legible on the dark surface; fallback SVGs keep their official250 * to stay legible on the dark surface; fallback SVGs keep their official
251 * colors and get no plate. */251 * colors and get no plate. */
252-.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }252+.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; width: 100%; min-width: 0; }
253 .choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; }253 .choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; }
254 .choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; }254 .choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; }
255 .choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; }255 .choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; }
modified packages/config-page/test/catalog-client.test.js +32 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/test/config-page.test.ts +12 -1
Large diff collapsed to keep the page fast — load it when you need it.
added packages/config-page/test/config-site-build.test.ts +79 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/contracts/src/index.ts +3 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/contracts/test/contracts.test.ts +3 -0
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/.lock-waf_linux_build +0 -10
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/package.json +8 -1
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/resources/images/menu-icon.png +0 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/ui.js +16 -7
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/runtime.test.js +54 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/ui.test.js +36 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/build-config-site.mjs +41 -22
Large diff collapsed to keep the page fast — load it when you need it.