fix(traffic): include active works in disruption selection
a259698 parent: 8f2b8fe modified
CHANGELOG.md +6 -0 | @@ -4,6 +4,12 @@ | ||
| 4 | 4 | |
| 5 | 5 | Catalog rebuilds and publication now run independently of settings-page deployment and watch releases. |
| 6 | 6 | |
| 7 | +Active works now participate in traffic reporting according to their severity, rather than being discarded solely because their cause is `TRAVAUX`. Future, expired, and information-only notices remain excluded. | |
| 8 | + | |
| 9 | +### Fixes | |
| 10 | + | |
| 11 | +- fix(traffic): include active works in disruption selection and preserve the exclusive end-of-period boundary. | |
| 12 | + | |
| 7 | 13 | ### Changes |
| 8 | 14 | |
| 9 | 15 | - ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support. |
| @@ -4,6 +4,12 @@ | |||
| 4 | 4 | ||
| 5 | Catalog rebuilds and publication now run independently of settings-page deployment and watch releases. | 5 | Catalog rebuilds and publication now run independently of settings-page deployment and watch releases. |
| 6 | 6 | ||
| 7 | +Active works now participate in traffic reporting according to their severity, rather than being discarded solely because their cause is `TRAVAUX`. Future, expired, and information-only notices remain excluded. | ||
| 8 | + | ||
| 9 | +### Fixes | ||
| 10 | + | ||
| 11 | +- fix(traffic): include active works in disruption selection and preserve the exclusive end-of-period boundary. | ||
| 12 | + | ||
| 7 | ### Changes | 13 | ### Changes |
| 8 | 14 | ||
| 9 | - ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support. | 15 | - ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support. |
modified
README.md +1 -1 | @@ -43,7 +43,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The | ||
| 43 | 43 | |
| 44 | 44 | - **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. |
| 45 | 45 | - **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes. |
| 46 | -- **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. | |
| 46 | +- **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Active works participate in the same selection and can report stopped or delayed service; future, expired, and information-only notices remain excluded. Periods are reevaluated on existing request triggers, not by a new timer. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. | |
| 47 | 47 | - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. |
| 48 | 48 | - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`). |
| 49 | 49 | - **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language. |
| @@ -43,7 +43,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The | |||
| 43 | 43 | ||
| 44 | - **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. | 44 | - **Six favorite journeys, five transport modes.** A favorite is a departure stop, a line, and a reachable arrival stop, including intermediate stops that no service terminates at. Journeys stay on one line, without connections, across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors. |
| 45 | - **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes. | 45 | - **Arrivals at a glance.** The favorites list shows the next arrival for each journey; Select opens its arrival board. Confirmed full-line and short-turn services are combined chronologically. Departures known not to serve the journey are excluded; uncertain departures follow the confirmed ones, marked with `?` and retaining their real times. Conflicting terminal hints can still confirm a journey when every candidate route serves its arrival. Countdowns keep ticking on the watch between refreshes. |
| 46 | -- **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. | 46 | +- **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Active works participate in the same selection and can report stopped or delayed service; future, expired, and information-only notices remain excluded. Periods are reevaluated on existing request triggers, not by a new timer. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. |
| 47 | - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. | 47 | - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. |
| 48 | - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`). | 48 | - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`). |
| 49 | - **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language. | 49 | - **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language. |
modified
packages/companion/src/prim-traffic.js +1 -3 | @@ -298,9 +298,7 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | ||
| 298 | 298 | } |
| 299 | 299 | for (index = 0; index < line.length; index += 1) { |
| 300 | 300 | disruption = envelope.disruptions[line[index]]; |
| 301 | - // Works never surface as traffic, including works already under way. | |
| 302 | - if (disruption.cause === "TRAVAUX" || !active(disruption, localNow) | |
| 303 | - || disruption.severity === "INFORMATION") continue; | |
| 301 | + if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue; | |
| 304 | 302 | updatedAt = disruption.lastUpdate || ""; |
| 305 | 303 | bestUpdatedAt = typeof best === "undefined" ? "" : best.lastUpdate || ""; |
| 306 | 304 | if (typeof best === "undefined" || updatedAt > bestUpdatedAt |
| @@ -298,9 +298,7 @@ function trafficForLine(envelope, lineId, evaluatedAtMilliseconds, checkedAtMill | |||
| 298 | } | 298 | } |
| 299 | for (index = 0; index < line.length; index += 1) { | 299 | for (index = 0; index < line.length; index += 1) { |
| 300 | disruption = envelope.disruptions[line[index]]; | 300 | disruption = envelope.disruptions[line[index]]; |
| 301 | - // Works never surface as traffic, including works already under way. | 301 | + if (!active(disruption, localNow) || disruption.severity === "INFORMATION") continue; |
| 302 | - if (disruption.cause === "TRAVAUX" || !active(disruption, localNow) | ||
| 303 | - || disruption.severity === "INFORMATION") continue; | ||
| 304 | updatedAt = disruption.lastUpdate || ""; | 302 | updatedAt = disruption.lastUpdate || ""; |
| 305 | bestUpdatedAt = typeof best === "undefined" ? "" : best.lastUpdate || ""; | 303 | bestUpdatedAt = typeof best === "undefined" ? "" : best.lastUpdate || ""; |
| 306 | if (typeof best === "undefined" || updatedAt > bestUpdatedAt | 304 | if (typeof best === "undefined" || updatedAt > bestUpdatedAt |
modified
packages/companion/test/prim-normalization.test.js +38 -16 | @@ -427,7 +427,7 @@ test("dated incidents outrank undated ones and equal dates use ID rather than in | ||
| 427 | 427 | assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident"); |
| 428 | 428 | }); |
| 429 | 429 | |
| 430 | -test("newer future and active works are excluded and cannot mask older active incidents", function () { | |
| 430 | +test("future works stay ignored while active works participate in the selection", function () { | |
| 431 | 431 | var payload = trafficFixture(); |
| 432 | 432 | payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay", "blocking-unclassified"]; |
| 433 | 433 | payload.disruptions[3].lastUpdate = "20260115T095900"; |
| @@ -435,25 +435,47 @@ test("newer future and active works are excluded and cannot mask older active in | ||
| 435 | 435 | payload.disruptions[4].lastUpdate = "20260115T100000"; |
| 436 | 436 | assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements"); |
| 437 | 437 | payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000"; |
| 438 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements"); | |
| 438 | + assert.deepEqual(trafficDetail(payload, "IDFM:C200"), { | |
| 439 | + schemaVersion: 1, state: "DELAYED", checkedAt: TRAFFIC_MS / 1000, | |
| 440 | + title: "Travaux prévus", text: "Des travaux sont prévus plus tard." | |
| 441 | + }); | |
| 439 | 442 | payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"]; |
| 440 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL"); | |
| 443 | + assert.equal(trafficDetail(payload, "IDFM:C200").state, "DELAYED"); | |
| 441 | 444 | }); |
| 442 | 445 | |
| 443 | -test("works exclusion follows the explicit TRAVAUX cause, never the title, a missing or unknown cause", function () { | |
| 446 | +test("active works interruption tracks the synthetic September window, inclusive at 08:00 and gone at noon", function () { | |
| 444 | 447 | var payload = trafficFixture(); |
| 445 | - payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay"]; | |
| 446 | - payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000"; | |
| 447 | - payload.disruptions[4].lastUpdate = "20260115T100000"; | |
| 448 | - payload.disruptions[0].lastUpdate = "20260115T090000"; | |
| 449 | - payload.disruptions[0].title = "Travaux en cours sur la ligne"; | |
| 450 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne"); | |
| 451 | - payload.disruptions[0].cause = "TRAVAUX"; | |
| 452 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL"); | |
| 453 | - payload.disruptions[0].cause = "MANIFESTATION"; | |
| 454 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne"); | |
| 455 | - delete payload.disruptions[0].cause; | |
| 456 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne"); | |
| 448 | + payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"]; | |
| 449 | + payload.disruptions[4].severity = "BLOQUANTE"; | |
| 450 | + payload.disruptions[4].applicationPeriods = [{ begin: "20260920T080000", end: "20260920T120000" }]; | |
| 451 | + payload.disruptions[4].lastUpdate = "20260920T070000"; | |
| 452 | + payload.disruptions[4].title = "Interruption pour travaux"; | |
| 453 | + payload.disruptions[4].message = "Service interrompu jusqu'à midi."; | |
| 454 | + function worksAt(iso) { | |
| 455 | + var ms = Date.parse(iso); | |
| 456 | + return trafficDetail(payload, "IDFM:C200", ms, ms); | |
| 457 | + } | |
| 458 | + assert.equal(worksAt("2026-09-20T05:59:59Z").state, "NORMAL"); | |
| 459 | + var interrupted = worksAt("2026-09-20T06:00:00Z"); | |
| 460 | + assert.deepEqual(interrupted, { | |
| 461 | + schemaVersion: 1, state: "STOPPED", checkedAt: Date.parse("2026-09-20T06:00:00Z") / 1000, | |
| 462 | + title: "Interruption pour travaux", text: "Service interrompu jusqu'à midi." | |
| 463 | + }); | |
| 464 | + assertTrafficContract(interrupted); | |
| 465 | + assert.equal(worksAt("2026-09-20T09:59:59Z").state, "STOPPED"); | |
| 466 | + assert.equal(worksAt("2026-09-20T10:00:00Z").state, "NORMAL"); | |
| 467 | + var activeAt = Date.parse("2026-09-20T08:00:00Z"); | |
| 468 | + var delayed = clone(payload); | |
| 469 | + delayed.disruptions[4].severity = "PERTURBEE"; | |
| 470 | + assert.equal(trafficDetail(delayed, "IDFM:C200", activeAt, activeAt).state, "DELAYED"); | |
| 471 | + var informational = clone(payload); | |
| 472 | + informational.disruptions[4].severity = "INFORMATION"; | |
| 473 | + assert.equal(trafficDetail(informational, "IDFM:C200", activeAt, activeAt).state, "NORMAL"); | |
| 474 | + var silent = clone(payload); | |
| 475 | + delete silent.disruptions[4].message; | |
| 476 | + assert.deepEqual(trafficDetail(silent, "IDFM:C200", activeAt, activeAt), { | |
| 477 | + schemaVersion: 1, state: "UNKNOWN", checkedAt: activeAt / 1000 | |
| 478 | + }); | |
| 457 | 479 | }); |
| 458 | 480 | |
| 459 | 481 | test("an active disruption without complete details remains UNKNOWN", function () { |
| @@ -427,7 +427,7 @@ test("dated incidents outrank undated ones and equal dates use ID rather than in | |||
| 427 | assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident"); | 427 | assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident"); |
| 428 | }); | 428 | }); |
| 429 | 429 | ||
| 430 | -test("newer future and active works are excluded and cannot mask older active incidents", function () { | 430 | +test("future works stay ignored while active works participate in the selection", function () { |
| 431 | var payload = trafficFixture(); | 431 | var payload = trafficFixture(); |
| 432 | payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay", "blocking-unclassified"]; | 432 | payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay", "blocking-unclassified"]; |
| 433 | payload.disruptions[3].lastUpdate = "20260115T095900"; | 433 | payload.disruptions[3].lastUpdate = "20260115T095900"; |
| @@ -435,25 +435,47 @@ test("newer future and active works are excluded and cannot mask older active in | |||
| 435 | payload.disruptions[4].lastUpdate = "20260115T100000"; | 435 | payload.disruptions[4].lastUpdate = "20260115T100000"; |
| 436 | assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements"); | 436 | assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements"); |
| 437 | payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000"; | 437 | payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000"; |
| 438 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements"); | 438 | + assert.deepEqual(trafficDetail(payload, "IDFM:C200"), { |
| 439 | + schemaVersion: 1, state: "DELAYED", checkedAt: TRAFFIC_MS / 1000, | ||
| 440 | + title: "Travaux prévus", text: "Des travaux sont prévus plus tard." | ||
| 441 | + }); | ||
| 439 | payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"]; | 442 | payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"]; |
| 440 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL"); | 443 | + assert.equal(trafficDetail(payload, "IDFM:C200").state, "DELAYED"); |
| 441 | }); | 444 | }); |
| 442 | 445 | ||
| 443 | -test("works exclusion follows the explicit TRAVAUX cause, never the title, a missing or unknown cause", function () { | 446 | +test("active works interruption tracks the synthetic September window, inclusive at 08:00 and gone at noon", function () { |
| 444 | var payload = trafficFixture(); | 447 | var payload = trafficFixture(); |
| 445 | - payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay"]; | 448 | + payload.lines[1].impactedObjects[0].disruptionIds = ["future-delay"]; |
| 446 | - payload.disruptions[4].applicationPeriods[0].begin = "20260115T100000"; | 449 | + payload.disruptions[4].severity = "BLOQUANTE"; |
| 447 | - payload.disruptions[4].lastUpdate = "20260115T100000"; | 450 | + payload.disruptions[4].applicationPeriods = [{ begin: "20260920T080000", end: "20260920T120000" }]; |
| 448 | - payload.disruptions[0].lastUpdate = "20260115T090000"; | 451 | + payload.disruptions[4].lastUpdate = "20260920T070000"; |
| 449 | - payload.disruptions[0].title = "Travaux en cours sur la ligne"; | 452 | + payload.disruptions[4].title = "Interruption pour travaux"; |
| 450 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne"); | 453 | + payload.disruptions[4].message = "Service interrompu jusqu'à midi."; |
| 451 | - payload.disruptions[0].cause = "TRAVAUX"; | 454 | + function worksAt(iso) { |
| 452 | - assert.equal(trafficDetail(payload, "IDFM:C200").state, "NORMAL"); | 455 | + var ms = Date.parse(iso); |
| 453 | - payload.disruptions[0].cause = "MANIFESTATION"; | 456 | + return trafficDetail(payload, "IDFM:C200", ms, ms); |
| 454 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne"); | 457 | + } |
| 455 | - delete payload.disruptions[0].cause; | 458 | + assert.equal(worksAt("2026-09-20T05:59:59Z").state, "NORMAL"); |
| 456 | - assert.equal(trafficDetail(payload, "IDFM:C200").title, "Travaux en cours sur la ligne"); | 459 | + var interrupted = worksAt("2026-09-20T06:00:00Z"); |
| 460 | + assert.deepEqual(interrupted, { | ||
| 461 | + schemaVersion: 1, state: "STOPPED", checkedAt: Date.parse("2026-09-20T06:00:00Z") / 1000, | ||
| 462 | + title: "Interruption pour travaux", text: "Service interrompu jusqu'à midi." | ||
| 463 | + }); | ||
| 464 | + assertTrafficContract(interrupted); | ||
| 465 | + assert.equal(worksAt("2026-09-20T09:59:59Z").state, "STOPPED"); | ||
| 466 | + assert.equal(worksAt("2026-09-20T10:00:00Z").state, "NORMAL"); | ||
| 467 | + var activeAt = Date.parse("2026-09-20T08:00:00Z"); | ||
| 468 | + var delayed = clone(payload); | ||
| 469 | + delayed.disruptions[4].severity = "PERTURBEE"; | ||
| 470 | + assert.equal(trafficDetail(delayed, "IDFM:C200", activeAt, activeAt).state, "DELAYED"); | ||
| 471 | + var informational = clone(payload); | ||
| 472 | + informational.disruptions[4].severity = "INFORMATION"; | ||
| 473 | + assert.equal(trafficDetail(informational, "IDFM:C200", activeAt, activeAt).state, "NORMAL"); | ||
| 474 | + var silent = clone(payload); | ||
| 475 | + delete silent.disruptions[4].message; | ||
| 476 | + assert.deepEqual(trafficDetail(silent, "IDFM:C200", activeAt, activeAt), { | ||
| 477 | + schemaVersion: 1, state: "UNKNOWN", checkedAt: activeAt / 1000 | ||
| 478 | + }); | ||
| 457 | }); | 479 | }); |
| 458 | 480 | ||
| 459 | test("an active disruption without complete details remains UNKNOWN", function () { | 481 | test("an active disruption without complete details remains UNKNOWN", function () { |