anthodev/lapin-futepublic Fork
⑂ 835b18b
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)Unverified

* feat: harden stale and failure handling

Bound useful stale departures independently from the fresh cache TTL, expose credential-free quota counts, and lock failure, key lifecycle, rapid navigation, and soak behavior with tests.

* ci: deploy configuration site to Bunny

* ci: deploy preview tags to Bunny

* ci: parallelize Bunny uploads

* feat: build configuration page without catalog

* ci: deploy configuration page independently

* fix: prioritize rail stops in catalog search

* fix: ignore French join words in catalog search

* fix: accept decoded Android settings responses

* fix: show planned downtime for empty departures

* style: strengthen departure detail hierarchy

* fix: use supported countdown font

* fix: add watch app menu icon

* style: increase launcher icon contrast

* style: preserve launcher icon contrast

* style: use dark launcher icon

* fix: fill overview rows at list boundaries

* style: balance departure countdown spacing

* ci: deploy releases from version tags

* ci: publish GitHub releases after deployment

* ci: categorize generated release notes

* fix: detect blocking traffic disruptions

* fix: treat omitted traffic lines as normal

* fix: label bus services by actual terminus

* chore: stop tracking agent instructions
Cédric Anthony committed 2026-09-13T12:18:33+02:00 Browse files
835b18b parent: 2799b0f
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
@@ -1,3 +1,4 @@
1+const FRENCH_JOIN_WORDS = new Set(["d", "de", "du", "des", "l", "la", "le", "les"]);
12
23 export function normalizeCatalogSearchText(value) {
34 return value
@@ -9,6 +10,13 @@ export function normalizeCatalogSearchText(value) {
910 .replace(/\s+/gu, " ");
1011 }
1112
13+export function normalizeCatalogSearchQuery(value) {
14+ return normalizeCatalogSearchText(value)
15+ .split(" ")
16+ .filter((token) => !FRENCH_JOIN_WORDS.has(token))
17+ .join(" ");
18+}
19+
1220 export function catalogSearchBucket(token) {
1321 return Array.from(token).slice(0, 2).map((character) =>
1422 character.codePointAt(0).toString(16)).join("_");
@@ -1,3 +1,4 @@
1+const FRENCH_JOIN_WORDS = new Set(["d", "de", "du", "des", "l", "la", "le", "les"]);
1 2
2 export function normalizeCatalogSearchText(value) {3 export function normalizeCatalogSearchText(value) {
3 return value4 return value
@@ -9,6 +10,13 @@ export function normalizeCatalogSearchText(value) {
9 .replace(/\s+/gu, " ");10 .replace(/\s+/gu, " ");
10 }11 }
11 12
13+export function normalizeCatalogSearchQuery(value) {
14+ return normalizeCatalogSearchText(value)
15+ .split(" ")
16+ .filter((token) => !FRENCH_JOIN_WORDS.has(token))
17+ .join(" ");
18+}
19+
12 export function catalogSearchBucket(token) {20 export function catalogSearchBucket(token) {
13 return Array.from(token).slice(0, 2).map((character) =>21 return Array.from(token).slice(0, 2).map((character) =>
14 character.codePointAt(0).toString(16)).join("_");22 character.codePointAt(0).toString(16)).join("_");
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
@@ -109,6 +109,20 @@ test("search rows carry precomputed lines without extra requests and reject malf
109109 }
110110 });
111111
112+test("equally relevant stops put rail modes before tram and bus", async () => {
113+ const modes = ["BUS", "TRAM", "TRANSILIEN", "RER", "METRO"];
114+ const entries = modes.map((mode, index) => ({
115+ ...place(index, "Saint-Denis - Université", "Saint-Denis"),
116+ mode,
117+ }));
118+ const { client } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
119+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: entries }));
120+
121+ const results = await client.searchPlaces("universite saint denis");
122+
123+ assert.deepEqual(results.map((entry) => entry.mode), ["METRO", "RER", "TRANSILIEN", "TRAM", "BUS"]);
124+});
125+
112126 test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => {
113127 const match = place(1, "Rue Saint-Denis Châtelet");
114128 const noChatelet = place(2, "Saint-Denis");
@@ -123,6 +137,24 @@ test("static search uses AND token prefixes, Unicode normalization and one-codep
123137 await assert.rejects(client.searchPlaces("--"), (error) => error.code === "INVALID_QUERY");
124138 });
125139
140+test("search ignores French join words absent from an official stop label", async () => {
141+ const mairie = place(1, "Mairie / Pelletier", "Stains");
142+ mairie.lines = [
143+ { lineLabel: "252", lineColor: "#ff0000", lineTextColor: "#000000" },
144+ { lineLabel: "253", lineColor: "#ffbe00", lineTextColor: "#000000" },
145+ { lineLabel: "255", lineColor: "#6e6e00", lineTextColor: "#ffffff" },
146+ { lineLabel: "N43", lineColor: "#ff5a00", lineTextColor: "#ffffff" },
147+ ];
148+ const { client, requests } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
149+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [mairie] }));
150+
151+ const results = await client.searchPlaces("Mairie de Stains");
152+
153+ assert.deepEqual(results.map((entry) => entry.placeId), [mairie.placeId]);
154+ assert.equal(requests[1], `catalog/${revision}/search/6d_61/0.json`);
155+ await assert.rejects(client.searchPlaces("de la"), (error) => error.code === "INVALID_QUERY");
156+});
157+
126158 test("missing initial search bucket is empty but a missing continuation is unavailable", async () => {
127159 const missing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) : response(null, 404));
128160 assert.deepEqual(await missing.client.searchPlaces("absent"), []);
@@ -109,6 +109,20 @@ test("search rows carry precomputed lines without extra requests and reject malf
109 }109 }
110 });110 });
111 111
112+test("equally relevant stops put rail modes before tram and bus", async () => {
113+ const modes = ["BUS", "TRAM", "TRANSILIEN", "RER", "METRO"];
114+ const entries = modes.map((mode, index) => ({
115+ ...place(index, "Saint-Denis - Université", "Saint-Denis"),
116+ mode,
117+ }));
118+ const { client } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
119+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: entries }));
120+
121+ const results = await client.searchPlaces("universite saint denis");
122+
123+ assert.deepEqual(results.map((entry) => entry.mode), ["METRO", "RER", "TRANSILIEN", "TRAM", "BUS"]);
124+});
125+
112 test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => {126 test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => {
113 const match = place(1, "Rue Saint-Denis Châtelet");127 const match = place(1, "Rue Saint-Denis Châtelet");
114 const noChatelet = place(2, "Saint-Denis");128 const noChatelet = place(2, "Saint-Denis");
@@ -123,6 +137,24 @@ test("static search uses AND token prefixes, Unicode normalization and one-codep
123 await assert.rejects(client.searchPlaces("--"), (error) => error.code === "INVALID_QUERY");137 await assert.rejects(client.searchPlaces("--"), (error) => error.code === "INVALID_QUERY");
124 });138 });
125 139
140+test("search ignores French join words absent from an official stop label", async () => {
141+ const mairie = place(1, "Mairie / Pelletier", "Stains");
142+ mairie.lines = [
143+ { lineLabel: "252", lineColor: "#ff0000", lineTextColor: "#000000" },
144+ { lineLabel: "253", lineColor: "#ffbe00", lineTextColor: "#000000" },
145+ { lineLabel: "255", lineColor: "#6e6e00", lineTextColor: "#ffffff" },
146+ { lineLabel: "N43", lineColor: "#ff5a00", lineTextColor: "#ffffff" },
147+ ];
148+ const { client, requests } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
149+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [mairie] }));
150+
151+ const results = await client.searchPlaces("Mairie de Stains");
152+
153+ assert.deepEqual(results.map((entry) => entry.placeId), [mairie.placeId]);
154+ assert.equal(requests[1], `catalog/${revision}/search/6d_61/0.json`);
155+ await assert.rejects(client.searchPlaces("de la"), (error) => error.code === "INVALID_QUERY");
156+});
157+
126 test("missing initial search bucket is empty but a missing continuation is unavailable", async () => {158 test("missing initial search bucket is empty but a missing continuation is unavailable", async () => {
127 const missing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) : response(null, 404));159 const missing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest) : response(null, 404));
128 assert.deepEqual(await missing.client.searchPlaces("absent"), []);160 assert.deepEqual(await missing.client.searchPlaces("absent"), []);
modified packages/config-page/test/config-page.test.ts +12 -1
@@ -863,7 +863,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
863863 "Publication director:",
864864 "Directeur de la publication :",
865865 "Anthodev",
866- "88 Colin P. Kelly Jr. St.",
866+ "BunnyWay d.o.o.",
867+ "Dunajska cesta 165",
868+ "https://bunny.net/privacy/",
867869 "localStorage",
868870 "no analytics or telemetry",
869871 "aucun cookie",
@@ -876,6 +878,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
876878 "ODbL 1.0",
877879 "github.com/Anthodev/lapin-fute",
878880 ]) assert.equal(html.includes(fact), true, fact);
881+ assert.doesNotMatch(html, /GitHub Pages|Lapin Futé backend|serveur Lapin Futé/iu);
882+ assert.match(html, /phone sends it directly to PRIM over HTTPS/u);
883+ assert.match(html, /téléphone l’envoie directement à PRIM en HTTPS/u);
879884 assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu);
880885 assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length);
881886 assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u);
@@ -913,6 +918,12 @@ test("favorites without presentation fields resolve no official image and use an
913918 assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);
914919 });
915920
921+test("search result labels use the available row width and wrap line badges", () => {
922+ const css = readFileSync(join(here, "../styles/config-page.css"), "utf8");
923+ assert.match(css, /\.choice-labels \{[^}]*flex: 1 1 auto;[^}]*min-width: 0;/u);
924+ assert.match(css, /\.choice-lines \{[^}]*flex-wrap: wrap;[^}]*width: 100%;[^}]*min-width: 0;/u);
925+});
926+
916927 test("mirrored place search validation matches the canonical lines contract", () => {
917928 const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };
918929 const placeId = `plc_${"0".repeat(43)}`;
@@ -863,7 +863,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
863 "Publication director:",863 "Publication director:",
864 "Directeur de la publication :",864 "Directeur de la publication :",
865 "Anthodev",865 "Anthodev",
866- "88 Colin P. Kelly Jr. St.",866+ "BunnyWay d.o.o.",
867+ "Dunajska cesta 165",
868+ "https://bunny.net/privacy/",
867 "localStorage",869 "localStorage",
868 "no analytics or telemetry",870 "no analytics or telemetry",
869 "aucun cookie",871 "aucun cookie",
@@ -876,6 +878,9 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
876 "ODbL 1.0",878 "ODbL 1.0",
877 "github.com/Anthodev/lapin-fute",879 "github.com/Anthodev/lapin-fute",
878 ]) assert.equal(html.includes(fact), true, fact);880 ]) assert.equal(html.includes(fact), true, fact);
881+ assert.doesNotMatch(html, /GitHub Pages|Lapin Futé backend|serveur Lapin Futé/iu);
882+ assert.match(html, /phone sends it directly to PRIM over HTTPS/u);
883+ assert.match(html, /téléphone l’envoie directement à PRIM en HTTPS/u);
879 assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu);884 assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu);
880 assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length);885 assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length);
881 assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u);886 assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u);
@@ -913,6 +918,12 @@ test("favorites without presentation fields resolve no official image and use an
913 assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);918 assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);
914 });919 });
915 920
921+test("search result labels use the available row width and wrap line badges", () => {
922+ const css = readFileSync(join(here, "../styles/config-page.css"), "utf8");
923+ assert.match(css, /\.choice-labels \{[^}]*flex: 1 1 auto;[^}]*min-width: 0;/u);
924+ assert.match(css, /\.choice-lines \{[^}]*flex-wrap: wrap;[^}]*width: 100%;[^}]*min-width: 0;/u);
925+});
926+
916 test("mirrored place search validation matches the canonical lines contract", () => {927 test("mirrored place search validation matches the canonical lines contract", () => {
917 const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };928 const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };
918 const placeId = `plc_${"0".repeat(43)}`;929 const placeId = `plc_${"0".repeat(43)}`;
added packages/config-page/test/config-site-build.test.ts +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import { buildConfigSite } from "../../../scripts/build-config-site.mjs";
7+
8+function fixture() {
9+ const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));
10+ const pageSource = join(root, "page");
11+ const staticPath = join(root, "catalog");
12+ const outputPath = join(root, "output");
13+ mkdirSync(pageSource);
14+ writeFileSync(join(pageSource, "index.html"), "<h1>Lapin Futé</h1>");
15+ for (const directory of ["src", "styles", "assets"]) {
16+ mkdirSync(join(pageSource, directory));
17+ writeFileSync(join(pageSource, directory, "fixture.txt"), directory);
18+ }
19+ return { root, pageSource, staticPath, outputPath };
20+}
21+
22+test("page-only configuration build neither requires nor publishes a catalog", () => {
23+ const paths = fixture();
24+ try {
25+ buildConfigSite({ ...paths, includeCatalog: false });
26+ assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>");
27+ assert.equal(existsSync(join(paths.outputPath, "catalog")), false);
28+ } finally {
29+ rmSync(paths.root, { recursive: true, force: true });
30+ }
31+});
32+
33+test("full configuration build still requires and copies a published catalog", () => {
34+ const paths = fixture();
35+ try {
36+ assert.throws(
37+ () => buildConfigSite(paths),
38+ /no published static catalog/u,
39+ );
40+
41+ mkdirSync(join(paths.staticPath, "revision"), { recursive: true });
42+ writeFileSync(join(paths.staticPath, "manifest.json"), '{"revision":"fixture"}');
43+ writeFileSync(join(paths.staticPath, "revision", "0.json"), "{}");
44+ buildConfigSite(paths);
45+
46+ assert.equal(
47+ readFileSync(join(paths.outputPath, "catalog", "manifest.json"), "utf8"),
48+ '{"revision":"fixture"}',
49+ );
50+ assert.equal(existsSync(join(paths.outputPath, "catalog", "revision", "0.json")), true);
51+ } finally {
52+ rmSync(paths.root, { recursive: true, force: true });
53+ }
54+});
55+
56+test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {
57+ const workflow = readFileSync(
58+ new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),
59+ "utf8",
60+ );
61+ assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u);
62+ assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);
63+ assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);
64+ assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, deploy\]/u);
65+ assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u);
66+ for (const title of ["Features", "Changes", "Fixes", "CI"]) {
67+ assert.match(workflow, new RegExp(`title: "${title}"`, "u"));
68+ }
69+ assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag/u);
70+ assert.doesNotMatch(workflow, /--generate-notes/u);
71+ assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u);
72+ assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u);
73+ assert.match(workflow, /inputs\.refresh_catalog != true[\s\S]+npm run build:config-page/u);
74+ assert.match(workflow, /inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u);
75+ assert.match(
76+ workflow,
77+ /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u,
78+ );
79+});
new file mode 100644
@@ -0,0 +1,79 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import { buildConfigSite } from "../../../scripts/build-config-site.mjs";
7+
8+function fixture() {
9+ const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));
10+ const pageSource = join(root, "page");
11+ const staticPath = join(root, "catalog");
12+ const outputPath = join(root, "output");
13+ mkdirSync(pageSource);
14+ writeFileSync(join(pageSource, "index.html"), "<h1>Lapin Futé</h1>");
15+ for (const directory of ["src", "styles", "assets"]) {
16+ mkdirSync(join(pageSource, directory));
17+ writeFileSync(join(pageSource, directory, "fixture.txt"), directory);
18+ }
19+ return { root, pageSource, staticPath, outputPath };
20+}
21+
22+test("page-only configuration build neither requires nor publishes a catalog", () => {
23+ const paths = fixture();
24+ try {
25+ buildConfigSite({ ...paths, includeCatalog: false });
26+ assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>");
27+ assert.equal(existsSync(join(paths.outputPath, "catalog")), false);
28+ } finally {
29+ rmSync(paths.root, { recursive: true, force: true });
30+ }
31+});
32+
33+test("full configuration build still requires and copies a published catalog", () => {
34+ const paths = fixture();
35+ try {
36+ assert.throws(
37+ () => buildConfigSite(paths),
38+ /no published static catalog/u,
39+ );
40+
41+ mkdirSync(join(paths.staticPath, "revision"), { recursive: true });
42+ writeFileSync(join(paths.staticPath, "manifest.json"), '{"revision":"fixture"}');
43+ writeFileSync(join(paths.staticPath, "revision", "0.json"), "{}");
44+ buildConfigSite(paths);
45+
46+ assert.equal(
47+ readFileSync(join(paths.outputPath, "catalog", "manifest.json"), "utf8"),
48+ '{"revision":"fixture"}',
49+ );
50+ assert.equal(existsSync(join(paths.outputPath, "catalog", "revision", "0.json")), true);
51+ } finally {
52+ rmSync(paths.root, { recursive: true, force: true });
53+ }
54+});
55+
56+test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {
57+ const workflow = readFileSync(
58+ new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),
59+ "utf8",
60+ );
61+ assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u);
62+ assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);
63+ assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);
64+ assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, deploy\]/u);
65+ assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u);
66+ for (const title of ["Features", "Changes", "Fixes", "CI"]) {
67+ assert.match(workflow, new RegExp(`title: "${title}"`, "u"));
68+ }
69+ assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag/u);
70+ assert.doesNotMatch(workflow, /--generate-notes/u);
71+ assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u);
72+ assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u);
73+ assert.match(workflow, /inputs\.refresh_catalog != true[\s\S]+npm run build:config-page/u);
74+ assert.match(workflow, /inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u);
75+ assert.match(
76+ workflow,
77+ /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u,
78+ );
79+});
modified packages/contracts/src/index.ts +3 -0
@@ -17,6 +17,9 @@ export const LIMITS = {
1717
1818 export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;
1919 export const CACHE_FRESH_SECONDS = 60 as const;
20+// A complete departure result may bridge a short source outage, but after
21+// fifteen minutes its times are no longer useful enough to show as data.
22+export const USEFUL_STALE_SECONDS = 15 * 60;
2023 export const FAVORITE_SETTLE_MS = 500 as const;
2124 export const APP_MESSAGE_INBOX_BYTES = 768 as const;
2225 export const APP_MESSAGE_OUTBOX_BYTES = 192 as const;
@@ -17,6 +17,9 @@ export const LIMITS = {
17 17
18 export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;18 export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;
19 export const CACHE_FRESH_SECONDS = 60 as const;19 export const CACHE_FRESH_SECONDS = 60 as const;
20+// A complete departure result may bridge a short source outage, but after
21+// fifteen minutes its times are no longer useful enough to show as data.
22+export const USEFUL_STALE_SECONDS = 15 * 60;
20 export const FAVORITE_SETTLE_MS = 500 as const;23 export const FAVORITE_SETTLE_MS = 500 as const;
21 export const APP_MESSAGE_INBOX_BYTES = 768 as const;24 export const APP_MESSAGE_INBOX_BYTES = 768 as const;
22 export const APP_MESSAGE_OUTBOX_BYTES = 192 as const;25 export const APP_MESSAGE_OUTBOX_BYTES = 192 as const;
modified packages/contracts/test/contracts.test.ts +3 -0
@@ -19,6 +19,7 @@ import {
1919 SCHEMA_VERSION,
2020 TRAFFIC_STATE,
2121 TRANSPORT_MODE,
22+ USEFUL_STALE_SECONDS,
2223 cstringBytes,
2324 dictionaryBytes,
2425 isApiKeyUpdate,
@@ -428,6 +429,8 @@ test("catalog contracts freeze transport modes, error codes, and limits", () =>
428429 assert.equal(LIMITS.catalogQueryMinCharacters, 2);
429430 assert.equal(LIMITS.catalogQueryMaxCharacters, 100);
430431 assert.equal(LIMITS.catalogSearchResults, 20);
432+ assert.equal(USEFUL_STALE_SECONDS, 15 * 60);
433+ assert.equal(companion.USEFUL_STALE_SECONDS, USEFUL_STALE_SECONDS);
431434 });
432435
433436 test("place lines require complete display metadata within the existing label and color bounds", () => {
@@ -19,6 +19,7 @@ import {
19 SCHEMA_VERSION,19 SCHEMA_VERSION,
20 TRAFFIC_STATE,20 TRAFFIC_STATE,
21 TRANSPORT_MODE,21 TRANSPORT_MODE,
22+ USEFUL_STALE_SECONDS,
22 cstringBytes,23 cstringBytes,
23 dictionaryBytes,24 dictionaryBytes,
24 isApiKeyUpdate,25 isApiKeyUpdate,
@@ -428,6 +429,8 @@ test("catalog contracts freeze transport modes, error codes, and limits", () =>
428 assert.equal(LIMITS.catalogQueryMinCharacters, 2);429 assert.equal(LIMITS.catalogQueryMinCharacters, 2);
429 assert.equal(LIMITS.catalogQueryMaxCharacters, 100);430 assert.equal(LIMITS.catalogQueryMaxCharacters, 100);
430 assert.equal(LIMITS.catalogSearchResults, 20);431 assert.equal(LIMITS.catalogSearchResults, 20);
432+ assert.equal(USEFUL_STALE_SECONDS, 15 * 60);
433+ assert.equal(companion.USEFUL_STALE_SECONDS, USEFUL_STALE_SECONDS);
431 });434 });
432 435
433 test("place lines require complete display metadata within the existing label and color bounds", () => {436 test("place lines require complete display metadata within the existing label and color bounds", () => {
deleted packages/watch/.lock-waf_linux_build +0 -10
deleted file mode 100644
@@ -1,10 +0,0 @@
1-argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']
2-config_cmd = 'configure'
3-environ = {'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'AGENT': '1', 'ATUIN_SESSION': '01a094d690ec7453a5369e821194a5d8', 'ATUIN_SHLVL': '2', 'ATUIN_TMUX_POPUP': 'false', 'AWS_PAGER': '', 'BAT_PAGER': 'cat', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'CI': 'true', 'CLAUDECODE': '1', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'COLORTERM': 'truecolor', 'COMPOSER_NO_INTERACTION': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'DEBIAN_FRONTEND': 'noninteractive', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DELTA_PAGER': 'cat', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'DISPLAY': ':0', 'DOCKER_USER': '1000:1000', 'EDITOR': 'true', 'GDK_CORE_DEVICE_EVENTS': '1', 'GHOSTTY_BIN_DIR': '/usr/bin', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'GH_PAGER': 'cat', 'GH_PROMPT_DISABLED': '1', 'GID': '1000', 'GIT_EDITOR': 'true', 'GIT_PAGER': 'cat', 'GIT_TERMINAL_PROMPT': '0', 'GLAB_PAGER': 'cat', 'GPG_TTY': 'not a tty', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.9.0/bin/herdr', 'HERDR_ENV': '1', 'HERDR_PANE_ID': 'w6:p2', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_STARTUP_CWD': '/home/anthodev', 'HERDR_TAB_ID': 'w6:t2', 'HERDR_WORKSPACE_ID': 'w6', 'HISTCONTROL': 'ignoredups', 'HISTSIZE': '1000', 'HOME': '/home/anthodev', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'HOMEBREW_PAGER': 'cat', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'ICEAUTHORITY': '/run/user/1000/iceauth_tVtQwG', 'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'IMSETTINGS_MODULE': 'none', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'KDEDIRS': '/usr', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'KDE_FULL_SESSION': 'true', 'KDE_SESSION_UID': '1000', 'KDE_SESSION_VERSION': '6', 'KWIN_DISABLE_TONEMAPPING': '1', 'LANG': 'fr_FR.UTF-8', 'LC_ALL': 'C.UTF-8', 'LESS': 'FRX', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'LOGNAME': 'anthodev', 'LS_COLORS': '', 'MAIL': '/var/spool/mail/anthodev', 'MANAGERPID': '2337', 'MANAGERPIDFDID': '467', 'MANPAGER': 'cat', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'MYSQL_PAGER': 'cat', 'NO_COLOR': '1', 'NVM_CD_FLAGS': '', 'NVM_DIR': '/home/anthodev/.nvm', 'OMPCODE': '1', 'PAGER': 'cat', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/anthodev/.spicetify:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'PIP_NO_INPUT': '1', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'PNPM_UPDATE_NOTIFIER': 'false', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'POSH_SHELL': 'fish', 'POSH_SHELL_VERSION': '4.9.3', 'POWERLINE_COMMAND': 'oh-my-posh', 'PSQL_PAGER': 'cat', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'PYTHONUNBUFFERED': '1', 'QT_WAYLAND_RECONNECT': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2974,unix/unix:/tmp/.ICE-unix/2974', 'SHLVL': '1', 'SSH_ASKPASS': '/usr/bin/false', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'STEAM_FRAME_FORCE_CLOSE': '1', 'SYSTEMD_EXEC_PID': '26807', 'SYSTEMD_PAGER': 'cat', 'TERM': 'dumb', 'TERMINFO': '/usr/share/terminfo', 'TERM_PROGRAM': 'ghostty', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'TF_INPUT': '0', 'TF_IN_AUTOMATION': '1', 'USER': 'anthodev', 'VISUAL': 'true', 'WAYLAND_DISPLAY': 'wayland-0', 'XAUTHORITY': '/run/user/1000/xauth_hwUhbA', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'XDG_CURRENT_DESKTOP': 'KDE', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'XDG_MENU_PREFIX': 'plasma-', 'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_SEAT': 'seat0', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'XDG_SESSION_CLASS': 'user', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_ID': '2', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'XDG_SESSION_TYPE': 'wayland', 'XDG_VTNR': '2', 'XKB_DEFAULT_LAYOUT': 'us', 'XKB_DEFAULT_MODEL': 'pc105', 'XKB_DEFAULT_VARIANT': 'intl', 'XMODIFIERS': '@im=none', 'YARN_ENABLE_PROGRESS_BARS': '0', 'YARN_ENABLE_TELEMETRY': '0', '_': '/usr/bin/node', '_JAVA_AWT_WM_NONREPARENTING': '1', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', '__PI_NATIVE_VARIANT_CACHE': 'modern', '_fish_env_initialized': '1', 'npm_config_audit': 'false', 'npm_config_fund': 'false', 'npm_config_progress': 'false', 'npm_config_update_notifier': 'false', 'npm_config_yes': 'true', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
4-files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']
5-hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'
6-launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
7-options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None}
8-out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build'
9-run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
10-top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
deleted file mode 100644
@@ -1,10 +0,0 @@
1-argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']
2-config_cmd = 'configure'
3-environ = {'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'AGENT': '1', 'ATUIN_SESSION': '01a094d690ec7453a5369e821194a5d8', 'ATUIN_SHLVL': '2', 'ATUIN_TMUX_POPUP': 'false', 'AWS_PAGER': '', 'BAT_PAGER': 'cat', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'CI': 'true', 'CLAUDECODE': '1', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'COLORTERM': 'truecolor', 'COMPOSER_NO_INTERACTION': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'DEBIAN_FRONTEND': 'noninteractive', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'DELTA_PAGER': 'cat', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'DISPLAY': ':0', 'DOCKER_USER': '1000:1000', 'EDITOR': 'true', 'GDK_CORE_DEVICE_EVENTS': '1', 'GHOSTTY_BIN_DIR': '/usr/bin', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'GH_PAGER': 'cat', 'GH_PROMPT_DISABLED': '1', 'GID': '1000', 'GIT_EDITOR': 'true', 'GIT_PAGER': 'cat', 'GIT_TERMINAL_PROMPT': '0', 'GLAB_PAGER': 'cat', 'GPG_TTY': 'not a tty', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.9.0/bin/herdr', 'HERDR_ENV': '1', 'HERDR_PANE_ID': 'w6:p2', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_STARTUP_CWD': '/home/anthodev', 'HERDR_TAB_ID': 'w6:t2', 'HERDR_WORKSPACE_ID': 'w6', 'HISTCONTROL': 'ignoredups', 'HISTSIZE': '1000', 'HOME': '/home/anthodev', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'HOMEBREW_PAGER': 'cat', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'ICEAUTHORITY': '/run/user/1000/iceauth_tVtQwG', 'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'IMSETTINGS_MODULE': 'none', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'KDEDIRS': '/usr', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'KDE_FULL_SESSION': 'true', 'KDE_SESSION_UID': '1000', 'KDE_SESSION_VERSION': '6', 'KWIN_DISABLE_TONEMAPPING': '1', 'LANG': 'fr_FR.UTF-8', 'LC_ALL': 'C.UTF-8', 'LESS': 'FRX', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'LOGNAME': 'anthodev', 'LS_COLORS': '', 'MAIL': '/var/spool/mail/anthodev', 'MANAGERPID': '2337', 'MANAGERPIDFDID': '467', 'MANPAGER': 'cat', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'MYSQL_PAGER': 'cat', 'NO_COLOR': '1', 'NVM_CD_FLAGS': '', 'NVM_DIR': '/home/anthodev/.nvm', 'OMPCODE': '1', 'PAGER': 'cat', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/anthodev/.spicetify:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'PIP_NO_INPUT': '1', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'PNPM_UPDATE_NOTIFIER': 'false', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'POSH_SHELL': 'fish', 'POSH_SHELL_VERSION': '4.9.3', 'POWERLINE_COMMAND': 'oh-my-posh', 'PSQL_PAGER': 'cat', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'PYTHONUNBUFFERED': '1', 'QT_WAYLAND_RECONNECT': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2974,unix/unix:/tmp/.ICE-unix/2974', 'SHLVL': '1', 'SSH_ASKPASS': '/usr/bin/false', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'STEAM_FRAME_FORCE_CLOSE': '1', 'SYSTEMD_EXEC_PID': '26807', 'SYSTEMD_PAGER': 'cat', 'TERM': 'dumb', 'TERMINFO': '/usr/share/terminfo', 'TERM_PROGRAM': 'ghostty', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'TF_INPUT': '0', 'TF_IN_AUTOMATION': '1', 'USER': 'anthodev', 'VISUAL': 'true', 'WAYLAND_DISPLAY': 'wayland-0', 'XAUTHORITY': '/run/user/1000/xauth_hwUhbA', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'XDG_CURRENT_DESKTOP': 'KDE', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'XDG_MENU_PREFIX': 'plasma-', 'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_SEAT': 'seat0', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'XDG_SESSION_CLASS': 'user', 'XDG_SESSION_DESKTOP': 'KDE', 'XDG_SESSION_ID': '2', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'XDG_SESSION_TYPE': 'wayland', 'XDG_VTNR': '2', 'XKB_DEFAULT_LAYOUT': 'us', 'XKB_DEFAULT_MODEL': 'pc105', 'XKB_DEFAULT_VARIANT': 'intl', 'XMODIFIERS': '@im=none', 'YARN_ENABLE_PROGRESS_BARS': '0', 'YARN_ENABLE_TELEMETRY': '0', '_': '/usr/bin/node', '_JAVA_AWT_WM_NONREPARENTING': '1', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', '__PI_NATIVE_VARIANT_CACHE': 'modern', '_fish_env_initialized': '1', 'npm_config_audit': 'false', 'npm_config_fund': 'false', 'npm_config_progress': 'false', 'npm_config_update_notifier': 'false', 'npm_config_yes': 'true', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
4-files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']
5-hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'
6-launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
7-options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None}
8-out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build'
9-run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
10-top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
modified packages/watch/package.json +8 -1
@@ -46,7 +46,14 @@
4646 "DISPLAY_EPOCH": 45
4747 },
4848 "resources": {
49- "media": []
49+ "media": [
50+ {
51+ "menuIcon": true,
52+ "type": "png",
53+ "name": "IMAGE_MENU_ICON",
54+ "file": "images/menu-icon.png"
55+ }
56+ ]
5057 }
5158 }
5259 }
@@ -46,7 +46,14 @@
46 "DISPLAY_EPOCH": 4546 "DISPLAY_EPOCH": 45
47 },47 },
48 "resources": {48 "resources": {
49- "media": []49+ "media": [
50+ {
51+ "menuIcon": true,
52+ "type": "png",
53+ "name": "IMAGE_MENU_ICON",
54+ "file": "images/menu-icon.png"
55+ }
56+ ]
50 }57 }
51 }58 }
52 }59 }
added packages/watch/resources/images/menu-icon.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/watch/resources/images/menu-icon.png differ
new file mode 100644
Binary files /dev/null and b/packages/watch/resources/images/menu-icon.png differBinary files /dev/null and b/packages/watch/resources/images/menu-icon.png differ
modified packages/watch/src/embeddedjs/ui.js +16 -7
@@ -19,6 +19,12 @@ function draw(port, text, style, color, x, y, width, height, align = 0) {
1919 const left = align < 0 ? x : align > 0 ? x + width - used : x + Math.floor((width-used)/2);
2020 port.drawString(text, fonts[style], color, left, y + ((height - measure.height) >> 1), used < measure.width ? used : 0);
2121 }
22+function drawBottom(port, text, style, color, x, bottom, width) {
23+ if (!text || width <= 0) return;
24+ const measure = port.measureString(text, fonts[style]);
25+ const used = Math.min(width, Math.ceil(measure.width));
26+ port.drawString(text, fonts[style], color, x, bottom-measure.height, used < measure.width ? used : 0);
27+}
2228 function lines(port, text, style, color, x, y, width, height) {
2329 let start = 0, count = 0;
2430 while (start <= text.length && text.length) {
@@ -104,7 +110,9 @@ function header(port,r,now,isStale,updating) {
104110 }
105111 }
106112 function overview(port,r,now) {
107- const start = Math.max(0,r.focus-1), end = Math.min(r.records.length,r.focus+1), round = r.profile === 1;
113+ const round = r.profile === 1, last = r.records.length;
114+ const start = round ? Math.max(0,r.focus-1) : Math.max(0,Math.min(r.focus-1,last-2));
115+ const end = round ? Math.min(last,r.focus+1) : Math.min(last,start+2);
108116 for (let i = start; i <= end; i++) {
109117 const relative = i-r.focus, compact = round && relative !== 0, focused = relative === 0;
110118 const x = round ? compact ? 54 : 30 : 6, y = round ? relative<0 ? 54 : relative>0 ? 176 : 92 : 34+(i-start)*62;
@@ -138,8 +146,8 @@ function departures(port,r,now) {
138146 let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35;
139147 chip(port,record,2,x,y+6,44,27);
140148 draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1);
141- draw(port,">",0,MUTED,x+52,y+20,10,18,-1);
142- draw(port,clipped(record,11),0,MUTED,x+62,y+20,width-62,18,-1);
149+ draw(port,">",1,MUTED,x+52,y+20,10,18,-1);
150+ draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1);
143151 port.fillColor(RULE,x,y+height-1,width,1);
144152 x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66;
145153 if (!count) {
@@ -147,10 +155,11 @@ function departures(port,r,now) {
147155 lines(port,text,1,INK,x,y+Math.max(0,Math.floor((height-lineCount(text)*16)/2)),width,16);
148156 } else {
149157 const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0;
150- const style=value==="-"?2:3, valueWidth=Math.min(width-36,Math.ceil(port.measureString(value,fonts[style]).width));
151- const left=x+Math.floor((width-valueWidth-(unit?34:0))/2);
152- draw(port,value,style,INK,left,y,valueWidth,height-18);
153- if(unit)draw(port,"min",1,INK,left+valueWidth+3,y+20,31,height-30,-1);
158+ const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0;
159+ const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width));
160+ const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(round?17:14);
161+ drawBottom(port,value,style,INK,left,bottom,valueWidth);
162+ if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth);
154163 if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18);
155164 }
156165 const rows=Math.min(round?2:3,Math.max(0,count-1));
@@ -19,6 +19,12 @@ function draw(port, text, style, color, x, y, width, height, align = 0) {
19 const left = align < 0 ? x : align > 0 ? x + width - used : x + Math.floor((width-used)/2);19 const left = align < 0 ? x : align > 0 ? x + width - used : x + Math.floor((width-used)/2);
20 port.drawString(text, fonts[style], color, left, y + ((height - measure.height) >> 1), used < measure.width ? used : 0);20 port.drawString(text, fonts[style], color, left, y + ((height - measure.height) >> 1), used < measure.width ? used : 0);
21 }21 }
22+function drawBottom(port, text, style, color, x, bottom, width) {
23+ if (!text || width <= 0) return;
24+ const measure = port.measureString(text, fonts[style]);
25+ const used = Math.min(width, Math.ceil(measure.width));
26+ port.drawString(text, fonts[style], color, x, bottom-measure.height, used < measure.width ? used : 0);
27+}
22 function lines(port, text, style, color, x, y, width, height) {28 function lines(port, text, style, color, x, y, width, height) {
23 let start = 0, count = 0;29 let start = 0, count = 0;
24 while (start <= text.length && text.length) {30 while (start <= text.length && text.length) {
@@ -104,7 +110,9 @@ function header(port,r,now,isStale,updating) {
104 }110 }
105 }111 }
106 function overview(port,r,now) {112 function overview(port,r,now) {
107- const start = Math.max(0,r.focus-1), end = Math.min(r.records.length,r.focus+1), round = r.profile === 1;113+ const round = r.profile === 1, last = r.records.length;
114+ const start = round ? Math.max(0,r.focus-1) : Math.max(0,Math.min(r.focus-1,last-2));
115+ const end = round ? Math.min(last,r.focus+1) : Math.min(last,start+2);
108 for (let i = start; i <= end; i++) {116 for (let i = start; i <= end; i++) {
109 const relative = i-r.focus, compact = round && relative !== 0, focused = relative === 0;117 const relative = i-r.focus, compact = round && relative !== 0, focused = relative === 0;
110 const x = round ? compact ? 54 : 30 : 6, y = round ? relative<0 ? 54 : relative>0 ? 176 : 92 : 34+(i-start)*62;118 const x = round ? compact ? 54 : 30 : 6, y = round ? relative<0 ? 54 : relative>0 ? 176 : 92 : 34+(i-start)*62;
@@ -138,8 +146,8 @@ function departures(port,r,now) {
138 let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35;146 let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35;
139 chip(port,record,2,x,y+6,44,27);147 chip(port,record,2,x,y+6,44,27);
140 draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1);148 draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1);
141- draw(port,">",0,MUTED,x+52,y+20,10,18,-1);149+ draw(port,">",1,MUTED,x+52,y+20,10,18,-1);
142- draw(port,clipped(record,11),0,MUTED,x+62,y+20,width-62,18,-1);150+ draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1);
143 port.fillColor(RULE,x,y+height-1,width,1);151 port.fillColor(RULE,x,y+height-1,width,1);
144 x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66;152 x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66;
145 if (!count) {153 if (!count) {
@@ -147,10 +155,11 @@ function departures(port,r,now) {
147 lines(port,text,1,INK,x,y+Math.max(0,Math.floor((height-lineCount(text)*16)/2)),width,16);155 lines(port,text,1,INK,x,y+Math.max(0,Math.floor((height-lineCount(text)*16)/2)),width,16);
148 } else {156 } else {
149 const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0;157 const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0;
150- const style=value==="-"?2:3, valueWidth=Math.min(width-36,Math.ceil(port.measureString(value,fonts[style]).width));158+ const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0;
151- const left=x+Math.floor((width-valueWidth-(unit?34:0))/2);159+ const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width));
152- draw(port,value,style,INK,left,y,valueWidth,height-18);160+ const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(round?17:14);
153- if(unit)draw(port,"min",1,INK,left+valueWidth+3,y+20,31,height-30,-1);161+ drawBottom(port,value,style,INK,left,bottom,valueWidth);
162+ if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth);
154 if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18);163 if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18);
155 }164 }
156 const rows=Math.min(round?2:3,Math.max(0,count-1));165 const rows=Math.min(round?2:3,Math.max(0,count-1));
modified packages/watch/test/runtime.test.js +54 -0
@@ -221,6 +221,60 @@ test("obsolete work is cancelled without becoming the new favorite's failure", (
221221 assert.equal(f6.timers.size, 0, "clock change cancels pre-BEGIN traffic expectations");
222222 });
223223
224+test("rapid detail navigation settles once on the final favorite after 500 ms", () => {
225+ const target = harness();
226+ const records = appearances(6);
227+ configure(target, records);
228+ data(target, 0, records.map(() => departure()));
229+ target.runtime.button("select");
230+ data(target, 1, [departure()]);
231+ const before = target.out.filter((message) => message.get(1) === 1).length;
232+ target.runtime.button("down");
233+ target.runtime.button("down");
234+ target.runtime.button("up");
235+ target.runtime.button("down");
236+ assert.equal(target.r.active, 2);
237+ assert.equal(target.timers.size, 1);
238+ assert.equal(target.out.filter((message) => message.get(1) === 1).length, before);
239+ const [timer] = target.timers.values();
240+ assert.equal(timer.delay, 500);
241+ target.timers.clear();
242+ timer.fn();
243+ const requests = target.out.filter((message) => message.get(1) === 1);
244+ assert.equal(requests.length, before + 1);
245+ assert.equal(requests.at(-1).get(3), field(records[2], 0));
246+ assert.equal(requests.at(-1).get(24), 1);
247+});
248+
249+test("fifty refresh and rapid-navigation cycles keep durable and timer state bounded", () => {
250+ const target = harness();
251+ const records = appearances(6);
252+ configure(target, records);
253+ data(target, 0, records.map(() => departure()));
254+ target.runtime.button("select");
255+ data(target, 1, [departure()]);
256+ const durableKeys = [...target.store.values.keys()].sort();
257+ const committedRecords = target.r.records;
258+ for (let cycle = 0; cycle < 50; cycle++) {
259+ target.runtime.button("selectLong");
260+ data(target, 1, [departure()]);
261+ target.runtime.button(cycle % 2 ? "up" : "down");
262+ target.runtime.button(cycle % 2 ? "down" : "up");
263+ assert.equal(target.timers.size, 1);
264+ const [timer] = target.timers.values();
265+ target.timers.clear();
266+ timer.fn();
267+ data(target, 1, [departure()]);
268+ assert.equal(target.r.pending, 0);
269+ assert.equal(target.r.candidate, null);
270+ }
271+ assert.equal(target.r.records, committedRecords);
272+ assert.deepEqual([...target.store.values.keys()].sort(), durableKeys);
273+ assert.equal(target.timers.size, 0);
274+ assert.equal(target.r.requests.length, 3);
275+ assert.equal(target.r.requests.filter(Boolean).length, 2);
276+});
277+
224278 test("enqueue failures belong to the attempted request, never unrelated sync", () => {
225279 const f7 = harness();
226280 readyDetail(f7);
@@ -221,6 +221,60 @@ test("obsolete work is cancelled without becoming the new favorite's failure", (
221 assert.equal(f6.timers.size, 0, "clock change cancels pre-BEGIN traffic expectations");221 assert.equal(f6.timers.size, 0, "clock change cancels pre-BEGIN traffic expectations");
222 });222 });
223 223
224+test("rapid detail navigation settles once on the final favorite after 500 ms", () => {
225+ const target = harness();
226+ const records = appearances(6);
227+ configure(target, records);
228+ data(target, 0, records.map(() => departure()));
229+ target.runtime.button("select");
230+ data(target, 1, [departure()]);
231+ const before = target.out.filter((message) => message.get(1) === 1).length;
232+ target.runtime.button("down");
233+ target.runtime.button("down");
234+ target.runtime.button("up");
235+ target.runtime.button("down");
236+ assert.equal(target.r.active, 2);
237+ assert.equal(target.timers.size, 1);
238+ assert.equal(target.out.filter((message) => message.get(1) === 1).length, before);
239+ const [timer] = target.timers.values();
240+ assert.equal(timer.delay, 500);
241+ target.timers.clear();
242+ timer.fn();
243+ const requests = target.out.filter((message) => message.get(1) === 1);
244+ assert.equal(requests.length, before + 1);
245+ assert.equal(requests.at(-1).get(3), field(records[2], 0));
246+ assert.equal(requests.at(-1).get(24), 1);
247+});
248+
249+test("fifty refresh and rapid-navigation cycles keep durable and timer state bounded", () => {
250+ const target = harness();
251+ const records = appearances(6);
252+ configure(target, records);
253+ data(target, 0, records.map(() => departure()));
254+ target.runtime.button("select");
255+ data(target, 1, [departure()]);
256+ const durableKeys = [...target.store.values.keys()].sort();
257+ const committedRecords = target.r.records;
258+ for (let cycle = 0; cycle < 50; cycle++) {
259+ target.runtime.button("selectLong");
260+ data(target, 1, [departure()]);
261+ target.runtime.button(cycle % 2 ? "up" : "down");
262+ target.runtime.button(cycle % 2 ? "down" : "up");
263+ assert.equal(target.timers.size, 1);
264+ const [timer] = target.timers.values();
265+ target.timers.clear();
266+ timer.fn();
267+ data(target, 1, [departure()]);
268+ assert.equal(target.r.pending, 0);
269+ assert.equal(target.r.candidate, null);
270+ }
271+ assert.equal(target.r.records, committedRecords);
272+ assert.deepEqual([...target.store.values.keys()].sort(), durableKeys);
273+ assert.equal(target.timers.size, 0);
274+ assert.equal(target.r.requests.length, 3);
275+ assert.equal(target.r.requests.filter(Boolean).length, 2);
276+});
277+
224 test("enqueue failures belong to the attempted request, never unrelated sync", () => {278 test("enqueue failures belong to the attempted request, never unrelated sync", () => {
225 const f7 = harness();279 const f7 = harness();
226 readyDetail(f7);280 readyDetail(f7);
modified packages/watch/test/ui.test.js +36 -0
@@ -1,5 +1,6 @@
11 import test from "node:test";
22 import assert from "node:assert/strict";
3+import { readFileSync } from "node:fs";
34 import { nativeHost } from "./native-host.js";
45 import { watchModule } from "./xs-host.js";
56 import {
@@ -8,6 +9,9 @@ import {
89 } from "./d2-records.js";
910
1011 const { field } = await watchModule("packed");
12+const supportedFonts = new Set(JSON.parse(readFileSync(
13+ new URL("../../companion/src/display-font-metrics.json", import.meta.url)
14+)).roles);
1115
1216 async function renderHarness(t, profile = 0) {
1317 nativeHost(t, profile);
@@ -105,6 +109,38 @@ for (const profile of [0, 1]) {
105109 });
106110 }
107111
112+for (const profile of [0, 1]) {
113+ test(`profile ${profile}: detail emphasizes the destination and aligns the primary countdown`, async (t) => {
114+ const h = await renderHarness(t, profile);
115+ configure(h, appearances(2, "fav", { profile }));
116+ data(h, 0, [departure(), departure()]);
117+ h.runtime.button("select");
118+ data(h, 1, [departure()]);
119+ const rows = h.show();
120+ const destination = rows.find((row) => row.text === "Vers 0");
121+ const value = rows.find((row) => row.text === "5");
122+ const unit = rows.find((row) => row.text === "min");
123+ assert(rows.every((row) => supportedFonts.has(row.font)));
124+ assert.equal(destination.font, "bold 14px Gothic");
125+ assert.equal(value.font, "bold 36px Gothic");
126+ assert.equal(unit.font, "bold 18px Gothic");
127+ assert.equal(value.y, unit.y);
128+ assert.equal(value.y, profile === 1 ? 129 : 108);
129+ });
130+}
131+
132+test("rectangular overview fills the first three rows", async (t) => {
133+ const h = await renderHarness(t);
134+ configure(h, appearances(2));
135+ data(h, 0, [departure(), departure()]);
136+ const firstRows = h.show();
137+ assert(firstRows.some((row) => row.text === "Tout actualiser"), JSON.stringify(firstRows));
138+
139+ configure(h, appearances(3, "next"));
140+ data(h, 0, [departure(), departure(), departure()]);
141+ assert(h.show().some((row) => row.text === "Arrêt 2"));
142+});
143+
108144 test("retained credential failures outrank traffic loading until matching success", async (t) => {
109145 const h = await renderHarness(t);
110146 readyDetail(h);
@@ -1,5 +1,6 @@
1 import test from "node:test";1 import test from "node:test";
2 import assert from "node:assert/strict";2 import assert from "node:assert/strict";
3+import { readFileSync } from "node:fs";
3 import { nativeHost } from "./native-host.js";4 import { nativeHost } from "./native-host.js";
4 import { watchModule } from "./xs-host.js";5 import { watchModule } from "./xs-host.js";
5 import {6 import {
@@ -8,6 +9,9 @@ import {
8 } from "./d2-records.js";9 } from "./d2-records.js";
9 10
10 const { field } = await watchModule("packed");11 const { field } = await watchModule("packed");
12+const supportedFonts = new Set(JSON.parse(readFileSync(
13+ new URL("../../companion/src/display-font-metrics.json", import.meta.url)
14+)).roles);
11 15
12 async function renderHarness(t, profile = 0) {16 async function renderHarness(t, profile = 0) {
13 nativeHost(t, profile);17 nativeHost(t, profile);
@@ -105,6 +109,38 @@ for (const profile of [0, 1]) {
105 });109 });
106 }110 }
107 111
112+for (const profile of [0, 1]) {
113+ test(`profile ${profile}: detail emphasizes the destination and aligns the primary countdown`, async (t) => {
114+ const h = await renderHarness(t, profile);
115+ configure(h, appearances(2, "fav", { profile }));
116+ data(h, 0, [departure(), departure()]);
117+ h.runtime.button("select");
118+ data(h, 1, [departure()]);
119+ const rows = h.show();
120+ const destination = rows.find((row) => row.text === "Vers 0");
121+ const value = rows.find((row) => row.text === "5");
122+ const unit = rows.find((row) => row.text === "min");
123+ assert(rows.every((row) => supportedFonts.has(row.font)));
124+ assert.equal(destination.font, "bold 14px Gothic");
125+ assert.equal(value.font, "bold 36px Gothic");
126+ assert.equal(unit.font, "bold 18px Gothic");
127+ assert.equal(value.y, unit.y);
128+ assert.equal(value.y, profile === 1 ? 129 : 108);
129+ });
130+}
131+
132+test("rectangular overview fills the first three rows", async (t) => {
133+ const h = await renderHarness(t);
134+ configure(h, appearances(2));
135+ data(h, 0, [departure(), departure()]);
136+ const firstRows = h.show();
137+ assert(firstRows.some((row) => row.text === "Tout actualiser"), JSON.stringify(firstRows));
138+
139+ configure(h, appearances(3, "next"));
140+ data(h, 0, [departure(), departure(), departure()]);
141+ assert(h.show().some((row) => row.text === "Arrêt 2"));
142+});
143+
108 test("retained credential failures outrank traffic loading until matching success", async (t) => {144 test("retained credential failures outrank traffic loading until matching success", async (t) => {
109 const h = await renderHarness(t);145 const h = await renderHarness(t);
110 readyDetail(h);146 readyDetail(h);
modified scripts/build-config-site.mjs +41 -22
@@ -17,54 +17,73 @@ const OUTPUT_PATH = join(PROJECT_ROOT, "var/config-site");
1717 const PAGE_ENTRIES = ["index.html", "src", "styles", "assets"];
1818
1919 function fail(message) {
20- console.error(`build:config-site failed: ${message}`);
21- process.exit(1);
20+ throw new Error(`build:config-site failed: ${message}`);
2221 }
2322
24-function copyCatalog() {
25- const manifest = join(STATIC_PATH, "manifest.json");
23+function copyCatalog(staticPath, outputPath) {
24+ const manifest = join(staticPath, "manifest.json");
2625 if (!existsSync(manifest)) {
27- fail(`no published static catalog at ${STATIC_PATH} (manifest.json missing). Run "npm run catalog:refresh" first.`);
26+ fail(`no published static catalog at ${staticPath} (manifest.json missing). Run "npm run catalog:refresh" first.`);
2827 }
2928
30- const catalogTarget = join(OUTPUT_PATH, "catalog");
29+ const catalogTarget = join(outputPath, "catalog");
3130 mkdirSync(catalogTarget, { recursive: true });
32- for (const entry of readdirSync(STATIC_PATH)) {
33- cpSync(join(STATIC_PATH, entry), join(catalogTarget, entry), { recursive: true });
31+ for (const entry of readdirSync(staticPath)) {
32+ cpSync(join(staticPath, entry), join(catalogTarget, entry), { recursive: true });
3433 }
3534 if (!existsSync(join(catalogTarget, "manifest.json"))) {
3635 fail("catalog copy did not produce manifest.json");
3736 }
3837 }
3938
40-function copyPage() {
39+function copyPage(pageSource, outputPath) {
4140 for (const entry of PAGE_ENTRIES) {
42- const source = join(PAGE_SOURCE, entry);
41+ const source = join(pageSource, entry);
4342 if (!existsSync(source)) fail(`missing config page source: ${entry}`);
4443 if (statSync(source).isDirectory()) {
45- cpSync(source, join(OUTPUT_PATH, entry), { recursive: true });
44+ cpSync(source, join(outputPath, entry), { recursive: true });
4645 } else {
47- cpSync(source, join(OUTPUT_PATH, entry));
46+ cpSync(source, join(outputPath, entry));
4847 }
4948 }
5049 }
5150
52-function build() {
53- if (!existsSync(STATIC_PATH)) {
54- fail(`no published static catalog at ${STATIC_PATH}. Run "npm run catalog:refresh" first.`);
51+export function buildConfigSite({
52+ pageSource = PAGE_SOURCE,
53+ staticPath = STATIC_PATH,
54+ outputPath = OUTPUT_PATH,
55+ includeCatalog = true,
56+} = {}) {
57+ if (includeCatalog && !existsSync(staticPath)) {
58+ fail(`no published static catalog at ${staticPath}. Run "npm run catalog:refresh" first.`);
5559 }
5660
57- rmSync(OUTPUT_PATH, { recursive: true, force: true });
58- mkdirSync(OUTPUT_PATH, { recursive: true });
61+ rmSync(outputPath, { recursive: true, force: true });
62+ mkdirSync(outputPath, { recursive: true });
5963 try {
60- copyPage();
61- copyCatalog();
64+ copyPage(pageSource, outputPath);
65+ if (includeCatalog) copyCatalog(staticPath, outputPath);
6266 } catch (error) {
63- rmSync(OUTPUT_PATH, { recursive: true, force: true });
67+ rmSync(outputPath, { recursive: true, force: true });
6468 throw error;
6569 }
6670
67- console.log(`config site ready: ${OUTPUT_PATH} (catalog revision in catalog/manifest.json)`);
71+ console.log(includeCatalog
72+ ? `config site ready: ${outputPath} (catalog revision in catalog/manifest.json)`
73+ : `config page ready: ${outputPath} (existing hosted catalog left unchanged)`);
6874 }
6975
70-if (process.argv[1] === fileURLToPath(import.meta.url)) build();
76+if (process.argv[1] === fileURLToPath(import.meta.url)) {
77+ const arguments_ = process.argv.slice(2);
78+ if (arguments_.some((argument) => argument !== "--page-only") || arguments_.length > 1) {
79+ console.error('build:config-site failed: the only supported option is "--page-only"');
80+ process.exitCode = 1;
81+ } else {
82+ try {
83+ buildConfigSite({ includeCatalog: arguments_[0] !== "--page-only" });
84+ } catch (error) {
85+ console.error(error instanceof Error ? error.message : error);
86+ process.exitCode = 1;
87+ }
88+ }
89+}
@@ -17,54 +17,73 @@ const OUTPUT_PATH = join(PROJECT_ROOT, "var/config-site");
17 const PAGE_ENTRIES = ["index.html", "src", "styles", "assets"];17 const PAGE_ENTRIES = ["index.html", "src", "styles", "assets"];
18 18
19 function fail(message) {19 function fail(message) {
20- console.error(`build:config-site failed: ${message}`);20+ throw new Error(`build:config-site failed: ${message}`);
21- process.exit(1);
22 }21 }
23 22
24-function copyCatalog() {23+function copyCatalog(staticPath, outputPath) {
25- const manifest = join(STATIC_PATH, "manifest.json");24+ const manifest = join(staticPath, "manifest.json");
26 if (!existsSync(manifest)) {25 if (!existsSync(manifest)) {
27- fail(`no published static catalog at ${STATIC_PATH} (manifest.json missing). Run "npm run catalog:refresh" first.`);26+ fail(`no published static catalog at ${staticPath} (manifest.json missing). Run "npm run catalog:refresh" first.`);
28 }27 }
29 28
30- const catalogTarget = join(OUTPUT_PATH, "catalog");29+ const catalogTarget = join(outputPath, "catalog");
31 mkdirSync(catalogTarget, { recursive: true });30 mkdirSync(catalogTarget, { recursive: true });
32- for (const entry of readdirSync(STATIC_PATH)) {31+ for (const entry of readdirSync(staticPath)) {
33- cpSync(join(STATIC_PATH, entry), join(catalogTarget, entry), { recursive: true });32+ cpSync(join(staticPath, entry), join(catalogTarget, entry), { recursive: true });
34 }33 }
35 if (!existsSync(join(catalogTarget, "manifest.json"))) {34 if (!existsSync(join(catalogTarget, "manifest.json"))) {
36 fail("catalog copy did not produce manifest.json");35 fail("catalog copy did not produce manifest.json");
37 }36 }
38 }37 }
39 38
40-function copyPage() {39+function copyPage(pageSource, outputPath) {
41 for (const entry of PAGE_ENTRIES) {40 for (const entry of PAGE_ENTRIES) {
42- const source = join(PAGE_SOURCE, entry);41+ const source = join(pageSource, entry);
43 if (!existsSync(source)) fail(`missing config page source: ${entry}`);42 if (!existsSync(source)) fail(`missing config page source: ${entry}`);
44 if (statSync(source).isDirectory()) {43 if (statSync(source).isDirectory()) {
45- cpSync(source, join(OUTPUT_PATH, entry), { recursive: true });44+ cpSync(source, join(outputPath, entry), { recursive: true });
46 } else {45 } else {
47- cpSync(source, join(OUTPUT_PATH, entry));46+ cpSync(source, join(outputPath, entry));
48 }47 }
49 }48 }
50 }49 }
51 50
52-function build() {51+export function buildConfigSite({
53- if (!existsSync(STATIC_PATH)) {52+ pageSource = PAGE_SOURCE,
54- fail(`no published static catalog at ${STATIC_PATH}. Run "npm run catalog:refresh" first.`);53+ staticPath = STATIC_PATH,
54+ outputPath = OUTPUT_PATH,
55+ includeCatalog = true,
56+} = {}) {
57+ if (includeCatalog && !existsSync(staticPath)) {
58+ fail(`no published static catalog at ${staticPath}. Run "npm run catalog:refresh" first.`);
55 }59 }
56 60
57- rmSync(OUTPUT_PATH, { recursive: true, force: true });61+ rmSync(outputPath, { recursive: true, force: true });
58- mkdirSync(OUTPUT_PATH, { recursive: true });62+ mkdirSync(outputPath, { recursive: true });
59 try {63 try {
60- copyPage();64+ copyPage(pageSource, outputPath);
61- copyCatalog();65+ if (includeCatalog) copyCatalog(staticPath, outputPath);
62 } catch (error) {66 } catch (error) {
63- rmSync(OUTPUT_PATH, { recursive: true, force: true });67+ rmSync(outputPath, { recursive: true, force: true });
64 throw error;68 throw error;
65 }69 }
66 70
67- console.log(`config site ready: ${OUTPUT_PATH} (catalog revision in catalog/manifest.json)`);71+ console.log(includeCatalog
72+ ? `config site ready: ${outputPath} (catalog revision in catalog/manifest.json)`
73+ : `config page ready: ${outputPath} (existing hosted catalog left unchanged)`);
68 }74 }
69 75
70-if (process.argv[1] === fileURLToPath(import.meta.url)) build();76+if (process.argv[1] === fileURLToPath(import.meta.url)) {
77+ const arguments_ = process.argv.slice(2);
78+ if (arguments_.some((argument) => argument !== "--page-only") || arguments_.length > 1) {
79+ console.error('build:config-site failed: the only supported option is "--page-only"');
80+ process.exitCode = 1;
81+ } else {
82+ try {
83+ buildConfigSite({ includeCatalog: arguments_[0] !== "--page-only" });
84+ } catch (error) {
85+ console.error(error instanceof Error ? error.message : error);
86+ process.exitCode = 1;
87+ }
88+ }
89+}