anthodev/lapin-futepublic Fork
⑂ a259698
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

fix(traffic): include active works in disruption selection

Cedric ANTHONY committed 2026-09-20T18:44:30+02:00 Browse files
a259698 parent: 8f2b8fe
modified CHANGELOG.md +6 -0
@@ -4,6 +4,12 @@
44
55 Catalog rebuilds and publication now run independently of settings-page deployment and watch releases.
66
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+
713 ### Changes
814
915 - 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 ### Changes13 ### 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
4343
4444 - **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.
4545 - **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.
4747 - **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.
4848 - **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 (`?`).
4949 - **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
298298 }
299299 for (index = 0; index < line.length; index += 1) {
300300 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;
304302 updatedAt = disruption.lastUpdate || "";
305303 bestUpdatedAt = typeof best === "undefined" ? "" : best.lastUpdate || "";
306304 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 > bestUpdatedAt304 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
427427 assert.equal(trafficDetail(payload, "IDFM:C200").title, "Tram T3 : incident");
428428 });
429429
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 () {
431431 var payload = trafficFixture();
432432 payload.lines[1].impactedObjects[0].disruptionIds = ["delay-active", "future-delay", "blocking-unclassified"];
433433 payload.disruptions[3].lastUpdate = "20260115T095900";
@@ -435,25 +435,47 @@ test("newer future and active works are excluded and cannot mask older active in
435435 payload.disruptions[4].lastUpdate = "20260115T100000";
436436 assert.equal(trafficDetail(payload, "IDFM:C200").title, "Métro 2 : ralentissements");
437437 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+ });
439442 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");
441444 });
442445
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 () {
444447 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+ });
457479 });
458480
459481 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 () {