fix(companion): pool partial-service arrivals and move store assets
2635f28 parent: 59caea0 deleted
.github/store-assets/emery_01-favorites.png +0 -0 | deleted file mode 100644 | ||
| Binary files a/.github/store-assets/emery_01-favorites.png and /dev/null differ | ||
| deleted file mode 100644 | |||
| Binary files a/.github/store-assets/emery_01-favorites.png and /dev/null differ | Binary files a/.github/store-assets/emery_01-favorites.png and /dev/null differ | ||
deleted
.github/store-assets/emery_02-departures.png +0 -0 | deleted file mode 100644 | ||
| Binary files a/.github/store-assets/emery_02-departures.png and /dev/null differ | ||
| deleted file mode 100644 | |||
| Binary files a/.github/store-assets/emery_02-departures.png and /dev/null differ | Binary files a/.github/store-assets/emery_02-departures.png and /dev/null differ | ||
deleted
.github/store-assets/emery_03-traffic.png +0 -0 | deleted file mode 100644 | ||
| Binary files a/.github/store-assets/emery_03-traffic.png and /dev/null differ | ||
| deleted file mode 100644 | |||
| Binary files a/.github/store-assets/emery_03-traffic.png and /dev/null differ | Binary files a/.github/store-assets/emery_03-traffic.png and /dev/null differ | ||
deleted
.github/store-assets/gabbro_01-favorites.png +0 -0 | deleted file mode 100644 | ||
| Binary files a/.github/store-assets/gabbro_01-favorites.png and /dev/null differ | ||
| deleted file mode 100644 | |||
| Binary files a/.github/store-assets/gabbro_01-favorites.png and /dev/null differ | Binary files a/.github/store-assets/gabbro_01-favorites.png and /dev/null differ | ||
deleted
.github/store-assets/gabbro_02-departures.png +0 -0 | deleted file mode 100644 | ||
| Binary files a/.github/store-assets/gabbro_02-departures.png and /dev/null differ | ||
| deleted file mode 100644 | |||
| Binary files a/.github/store-assets/gabbro_02-departures.png and /dev/null differ | Binary files a/.github/store-assets/gabbro_02-departures.png and /dev/null differ | ||
deleted
.github/store-assets/gabbro_03-traffic.png +0 -0 | deleted file mode 100644 | ||
| Binary files a/.github/store-assets/gabbro_03-traffic.png and /dev/null differ | ||
| deleted file mode 100644 | |||
| Binary files a/.github/store-assets/gabbro_03-traffic.png and /dev/null differ | Binary files a/.github/store-assets/gabbro_03-traffic.png and /dev/null differ | ||
modified
README.md +9 -1 | @@ -24,13 +24,21 @@ | ||
| 24 | 24 | <p align="center"> |
| 25 | 25 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 26 | 26 | <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> |
| 27 | - <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.1-FF4938" alt="Lapin Futé 1.0.1 on RePebble"></a> | |
| 28 | 27 | </p> |
| 29 | 28 | |
| 30 | 29 | ## About |
| 31 | 30 | |
| 32 | 31 | Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite stops with their next arrivals, an arrival board for each, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online. |
| 33 | 32 | |
| 33 | +<p align="center"> | |
| 34 | + <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%"> | |
| 35 | + <img src="assets/gabbro_01-favorites.png" alt="Lapin Futé favorites on Pebble Round 2" width="45%"> | |
| 36 | +</p> | |
| 37 | +<p align="center"> | |
| 38 | + <img src="assets/emery_02-departures.png" alt="Lapin Futé arrival board on Pebble Time 2" width="45%"> | |
| 39 | + <img src="assets/gabbro_02-departures.png" alt="Lapin Futé arrival board on Pebble Round 2" width="45%"> | |
| 40 | +</p> | |
| 41 | + | |
| 34 | 42 | ## Features |
| 35 | 43 | |
| 36 | 44 | - **Six favorite stops, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorite stops are renamed and reordered on the phone page, which shows official line badges and colors. |
| @@ -24,13 +24,21 @@ | |||
| 24 | <p align="center"> | 24 | <p align="center"> |
| 25 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> | 25 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 26 | <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> | 26 | <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> |
| 27 | - <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.1-FF4938" alt="Lapin Futé 1.0.1 on RePebble"></a> | ||
| 28 | </p> | 27 | </p> |
| 29 | 28 | ||
| 30 | ## About | 29 | ## About |
| 31 | 30 | ||
| 32 | Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite stops with their next arrivals, an arrival board for each, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online. | 31 | Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The watch shows a list of favorite stops with their next arrivals, an arrival board for each, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online. |
| 33 | 32 | ||
| 33 | +<p align="center"> | ||
| 34 | + <img src="assets/emery_01-favorites.png" alt="Lapin Futé favorites on Pebble Time 2" width="45%"> | ||
| 35 | + <img src="assets/gabbro_01-favorites.png" alt="Lapin Futé favorites on Pebble Round 2" width="45%"> | ||
| 36 | +</p> | ||
| 37 | +<p align="center"> | ||
| 38 | + <img src="assets/emery_02-departures.png" alt="Lapin Futé arrival board on Pebble Time 2" width="45%"> | ||
| 39 | + <img src="assets/gabbro_02-departures.png" alt="Lapin Futé arrival board on Pebble Round 2" width="45%"> | ||
| 40 | +</p> | ||
| 41 | + | ||
| 34 | ## Features | 42 | ## Features |
| 35 | 43 | ||
| 36 | - **Six favorite stops, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorite stops are renamed and reordered on the phone page, which shows official line badges and colors. | 44 | - **Six favorite stops, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorite stops are renamed and reordered on the phone page, which shows official line badges and colors. |
renamed
assets/README.md +5 -5 | similarity index 64% | ||
| rename from .github/store-assets/README.md | ||
| rename to assets/README.md | ||
| @@ -12,11 +12,11 @@ These files are prepared for the RePebble app listing: | ||
| 12 | 12 | | `gabbro_03-traffic.png` | 260 × 260 | Traffic status | |
| 13 | 13 | | `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner | |
| 14 | 14 | |
| 15 | -The screenshots are emulator framebuffer captures made with the project's | |
| 16 | -fixture data. The Emery captures have been normalized from the emulator's | |
| 17 | -backlight-off palette to its full-backlight palette; their UI content is | |
| 18 | -unchanged. They demonstrate the app UI but are not evidence of a production-data | |
| 19 | -or physical-watch test. | |
| 15 | +The screenshots are unretouched emulator framebuffer captures made with the | |
| 16 | +project's fixture data (fixture-only harness, zero live PRIM calls). The | |
| 17 | +favorites view shows a bus line and a metro line; the departures captures show | |
| 18 | +the same favorite and countdown values on both targets. They demonstrate the | |
| 19 | +app UI but are not evidence of a production-data or physical-watch test. | |
| 20 | 20 | |
| 21 | 21 | The banner is derived from the existing Lapin Futé visual identity and contains |
| 22 | 22 | no screenshot or watch mock-up. |
| similarity index 64% | |||
| rename from .github/store-assets/README.md | |||
| rename to assets/README.md | |||
| @@ -12,11 +12,11 @@ These files are prepared for the RePebble app listing: | |||
| 12 | | `gabbro_03-traffic.png` | 260 × 260 | Traffic status | | 12 | | `gabbro_03-traffic.png` | 260 × 260 | Traffic status | |
| 13 | | `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner | | 13 | | `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner | |
| 14 | 14 | ||
| 15 | -The screenshots are emulator framebuffer captures made with the project's | 15 | +The screenshots are unretouched emulator framebuffer captures made with the |
| 16 | -fixture data. The Emery captures have been normalized from the emulator's | 16 | +project's fixture data (fixture-only harness, zero live PRIM calls). The |
| 17 | -backlight-off palette to its full-backlight palette; their UI content is | 17 | +favorites view shows a bus line and a metro line; the departures captures show |
| 18 | -unchanged. They demonstrate the app UI but are not evidence of a production-data | 18 | +the same favorite and countdown values on both targets. They demonstrate the |
| 19 | -or physical-watch test. | 19 | +app UI but are not evidence of a production-data or physical-watch test. |
| 20 | 20 | ||
| 21 | The banner is derived from the existing Lapin Futé visual identity and contains | 21 | The banner is derived from the existing Lapin Futé visual identity and contains |
| 22 | no screenshot or watch mock-up. | 22 | no screenshot or watch mock-up. |
added
assets/emery_01-favorites.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/assets/emery_01-favorites.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/assets/emery_01-favorites.png differ | Binary files /dev/null and b/assets/emery_01-favorites.png differ | ||
added
assets/emery_02-departures.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/assets/emery_02-departures.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/assets/emery_02-departures.png differ | Binary files /dev/null and b/assets/emery_02-departures.png differ | ||
added
assets/emery_03-traffic.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/assets/emery_03-traffic.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/assets/emery_03-traffic.png differ | Binary files /dev/null and b/assets/emery_03-traffic.png differ | ||
added
assets/gabbro_01-favorites.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/assets/gabbro_01-favorites.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/assets/gabbro_01-favorites.png differ | Binary files /dev/null and b/assets/gabbro_01-favorites.png differ | ||
added
assets/gabbro_02-departures.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/assets/gabbro_02-departures.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/assets/gabbro_02-departures.png differ | Binary files /dev/null and b/assets/gabbro_02-departures.png differ | ||
added
assets/gabbro_03-traffic.png +0 -0 | new file mode 100644 | ||
| Binary files /dev/null and b/assets/gabbro_03-traffic.png differ | ||
| new file mode 100644 | |||
| Binary files /dev/null and b/assets/gabbro_03-traffic.png differ | Binary files /dev/null and b/assets/gabbro_03-traffic.png differ | ||
renamed
assets/lapin-fute-banner-720x320.png +0 -0 | similarity index 100% | ||
| rename from .github/store-assets/lapin-fute-banner-720x320.png | ||
| rename to assets/lapin-fute-banner-720x320.png |
| similarity index 100% | |||
| rename from .github/store-assets/lapin-fute-banner-720x320.png | |||
| rename to assets/lapin-fute-banner-720x320.png |
modified
packages/companion/src/prim-departures.js +21 -12 | @@ -4,6 +4,7 @@ var contracts = require("./contracts"); | ||
| 4 | 4 | var RFC3339_EXPLICIT_OFFSET = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/; |
| 5 | 5 | var RETRY_AFTER_DELTA = /^[0-9]+$/; |
| 6 | 6 | var STOP_MONITORING_URL = "https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring"; |
| 7 | +var QUAY_MONITORING_PREFIX = "STIF:StopPoint:Q:"; | |
| 7 | 8 | |
| 8 | 9 | function fail() { |
| 9 | 10 | throw new Error("Invalid PRIM stop-monitoring response"); |
| @@ -75,7 +76,7 @@ function sourceStatus(value) { | ||
| 75 | 76 | return "UNKNOWN"; |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | -function matchedJourney(visit, routing) { | |
| 79 | +function matchTier(visit, routing) { | |
| 79 | 80 | var monitoringRef = requiredRef(visit.MonitoringRef); |
| 80 | 81 | var journey = visit.MonitoredVehicleJourney; |
| 81 | 82 | var lineRef; |
| @@ -85,10 +86,14 @@ function matchedJourney(visit, routing) { | ||
| 85 | 86 | if (!contracts.isObject(journey.MonitoredCall)) fail(); |
| 86 | 87 | if (monitoringRef !== routing.monitoringRef || lineRef !== routing.lineRef) return undefined; |
| 87 | 88 | // SIRI DirectionRef is an operator label, not the catalog's GTFS direction_id. |
| 88 | - // Only an exact terminal reference establishes the selected service. | |
| 89 | + // A StopPoint:Q monitoring ref is a physical one-way quay, and PRIM labels | |
| 90 | + // partial (short-turn) trips with the full-line terminal quay, so a differing | |
| 91 | + // destination pools at tier 1 after the exact matches. StopArea refs (rail) | |
| 92 | + // span both directions and keep the exact destination requirement. | |
| 89 | 93 | destinationRef = requiredRef(journey.DestinationRef); |
| 90 | - if (destinationRef !== routing.destinationRef) return undefined; | |
| 91 | - return journey; | |
| 94 | + if (destinationRef === routing.destinationRef) return 0; | |
| 95 | + if (routing.monitoringRef.indexOf(QUAY_MONITORING_PREFIX) === 0) return 1; | |
| 96 | + return undefined; | |
| 92 | 97 | } |
| 93 | 98 | |
| 94 | 99 | function parseMatchedVisit(journey) { |
| @@ -105,11 +110,14 @@ function parseMatchedVisit(journey) { | ||
| 105 | 110 | }; |
| 106 | 111 | } |
| 107 | 112 | |
| 108 | -// Validate every visit, but retain only the first four in chronological order. | |
| 109 | -// Equal timestamps keep source order without relying on an ES5 engine's sort stability. | |
| 113 | +// Validate every visit, but retain only the first four, exact-destination | |
| 114 | +// matches first and then chronologically. Ties keep source order without | |
| 115 | +// relying on an ES5 engine's sort stability. | |
| 110 | 116 | function insertBounded(visits, visit) { |
| 111 | 117 | var index = visits.length; |
| 112 | - while (index > 0 && visits[index - 1].expectedAt > visit.expectedAt) index -= 1; | |
| 118 | + while (index > 0 && | |
| 119 | + (visits[index - 1].tier > visit.tier || | |
| 120 | + (visits[index - 1].tier === visit.tier && visits[index - 1].expectedAt > visit.expectedAt))) index -= 1; | |
| 113 | 121 | if (index >= contracts.LIMITS.departures) return; |
| 114 | 122 | visits.splice(index, 0, visit); |
| 115 | 123 | if (visits.length > contracts.LIMITS.departures) visits.pop(); |
| @@ -123,7 +131,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | ||
| 123 | 131 | var responseTimestamp; |
| 124 | 132 | var visits; |
| 125 | 133 | var visit; |
| 126 | - var journey; | |
| 134 | + var tier; | |
| 127 | 135 | var recordedAt; |
| 128 | 136 | var matched = []; |
| 129 | 137 | var departures = []; |
| @@ -158,13 +166,14 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | ||
| 158 | 166 | if (!contracts.isObject(visit)) fail(); |
| 159 | 167 | visitCount += 1; |
| 160 | 168 | recordedAt = epochSeconds(visit.RecordedAtTime); |
| 161 | - journey = matchedJourney(visit, routing); | |
| 162 | - if (typeof journey === "undefined") continue; | |
| 169 | + tier = matchTier(visit, routing); | |
| 170 | + if (typeof tier === "undefined") continue; | |
| 163 | 171 | if (typeof sourceUpdatedAt === "undefined" || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt; |
| 164 | - insertBounded(matched, parseMatchedVisit(journey)); | |
| 172 | + visit = parseMatchedVisit(visit.MonitoredVehicleJourney); | |
| 173 | + visit.tier = tier; | |
| 174 | + insertBounded(matched, visit); | |
| 165 | 175 | } |
| 166 | 176 | } |
| 167 | - if (matched.length === 0 && visitCount > 0) fail(); | |
| 168 | 177 | for (index = 0; index < matched.length; index += 1) { |
| 169 | 178 | visit = matched[index]; |
| 170 | 179 | if (visit.hasExpected) realtimeCount += 1; |
| @@ -4,6 +4,7 @@ var contracts = require("./contracts"); | |||
| 4 | var RFC3339_EXPLICIT_OFFSET = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/; | 4 | var RFC3339_EXPLICIT_OFFSET = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(Z|([+-])([0-9]{2}):([0-9]{2}))$/; |
| 5 | var RETRY_AFTER_DELTA = /^[0-9]+$/; | 5 | var RETRY_AFTER_DELTA = /^[0-9]+$/; |
| 6 | var STOP_MONITORING_URL = "https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring"; | 6 | var STOP_MONITORING_URL = "https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring"; |
| 7 | +var QUAY_MONITORING_PREFIX = "STIF:StopPoint:Q:"; | ||
| 7 | 8 | ||
| 8 | function fail() { | 9 | function fail() { |
| 9 | throw new Error("Invalid PRIM stop-monitoring response"); | 10 | throw new Error("Invalid PRIM stop-monitoring response"); |
| @@ -75,7 +76,7 @@ function sourceStatus(value) { | |||
| 75 | return "UNKNOWN"; | 76 | return "UNKNOWN"; |
| 76 | } | 77 | } |
| 77 | 78 | ||
| 78 | -function matchedJourney(visit, routing) { | 79 | +function matchTier(visit, routing) { |
| 79 | var monitoringRef = requiredRef(visit.MonitoringRef); | 80 | var monitoringRef = requiredRef(visit.MonitoringRef); |
| 80 | var journey = visit.MonitoredVehicleJourney; | 81 | var journey = visit.MonitoredVehicleJourney; |
| 81 | var lineRef; | 82 | var lineRef; |
| @@ -85,10 +86,14 @@ function matchedJourney(visit, routing) { | |||
| 85 | if (!contracts.isObject(journey.MonitoredCall)) fail(); | 86 | if (!contracts.isObject(journey.MonitoredCall)) fail(); |
| 86 | if (monitoringRef !== routing.monitoringRef || lineRef !== routing.lineRef) return undefined; | 87 | if (monitoringRef !== routing.monitoringRef || lineRef !== routing.lineRef) return undefined; |
| 87 | // SIRI DirectionRef is an operator label, not the catalog's GTFS direction_id. | 88 | // SIRI DirectionRef is an operator label, not the catalog's GTFS direction_id. |
| 88 | - // Only an exact terminal reference establishes the selected service. | 89 | + // A StopPoint:Q monitoring ref is a physical one-way quay, and PRIM labels |
| 90 | + // partial (short-turn) trips with the full-line terminal quay, so a differing | ||
| 91 | + // destination pools at tier 1 after the exact matches. StopArea refs (rail) | ||
| 92 | + // span both directions and keep the exact destination requirement. | ||
| 89 | destinationRef = requiredRef(journey.DestinationRef); | 93 | destinationRef = requiredRef(journey.DestinationRef); |
| 90 | - if (destinationRef !== routing.destinationRef) return undefined; | 94 | + if (destinationRef === routing.destinationRef) return 0; |
| 91 | - return journey; | 95 | + if (routing.monitoringRef.indexOf(QUAY_MONITORING_PREFIX) === 0) return 1; |
| 96 | + return undefined; | ||
| 92 | } | 97 | } |
| 93 | 98 | ||
| 94 | function parseMatchedVisit(journey) { | 99 | function parseMatchedVisit(journey) { |
| @@ -105,11 +110,14 @@ function parseMatchedVisit(journey) { | |||
| 105 | }; | 110 | }; |
| 106 | } | 111 | } |
| 107 | 112 | ||
| 108 | -// Validate every visit, but retain only the first four in chronological order. | 113 | +// Validate every visit, but retain only the first four, exact-destination |
| 109 | -// Equal timestamps keep source order without relying on an ES5 engine's sort stability. | 114 | +// matches first and then chronologically. Ties keep source order without |
| 115 | +// relying on an ES5 engine's sort stability. | ||
| 110 | function insertBounded(visits, visit) { | 116 | function insertBounded(visits, visit) { |
| 111 | var index = visits.length; | 117 | var index = visits.length; |
| 112 | - while (index > 0 && visits[index - 1].expectedAt > visit.expectedAt) index -= 1; | 118 | + while (index > 0 && |
| 119 | + (visits[index - 1].tier > visit.tier || | ||
| 120 | + (visits[index - 1].tier === visit.tier && visits[index - 1].expectedAt > visit.expectedAt))) index -= 1; | ||
| 113 | if (index >= contracts.LIMITS.departures) return; | 121 | if (index >= contracts.LIMITS.departures) return; |
| 114 | visits.splice(index, 0, visit); | 122 | visits.splice(index, 0, visit); |
| 115 | if (visits.length > contracts.LIMITS.departures) visits.pop(); | 123 | if (visits.length > contracts.LIMITS.departures) visits.pop(); |
| @@ -123,7 +131,7 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | |||
| 123 | var responseTimestamp; | 131 | var responseTimestamp; |
| 124 | var visits; | 132 | var visits; |
| 125 | var visit; | 133 | var visit; |
| 126 | - var journey; | 134 | + var tier; |
| 127 | var recordedAt; | 135 | var recordedAt; |
| 128 | var matched = []; | 136 | var matched = []; |
| 129 | var departures = []; | 137 | var departures = []; |
| @@ -158,13 +166,14 @@ function normalizePrimDepartureResponse(value, routing, fetchedAt) { | |||
| 158 | if (!contracts.isObject(visit)) fail(); | 166 | if (!contracts.isObject(visit)) fail(); |
| 159 | visitCount += 1; | 167 | visitCount += 1; |
| 160 | recordedAt = epochSeconds(visit.RecordedAtTime); | 168 | recordedAt = epochSeconds(visit.RecordedAtTime); |
| 161 | - journey = matchedJourney(visit, routing); | 169 | + tier = matchTier(visit, routing); |
| 162 | - if (typeof journey === "undefined") continue; | 170 | + if (typeof tier === "undefined") continue; |
| 163 | if (typeof sourceUpdatedAt === "undefined" || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt; | 171 | if (typeof sourceUpdatedAt === "undefined" || recordedAt > sourceUpdatedAt) sourceUpdatedAt = recordedAt; |
| 164 | - insertBounded(matched, parseMatchedVisit(journey)); | 172 | + visit = parseMatchedVisit(visit.MonitoredVehicleJourney); |
| 173 | + visit.tier = tier; | ||
| 174 | + insertBounded(matched, visit); | ||
| 165 | } | 175 | } |
| 166 | } | 176 | } |
| 167 | - if (matched.length === 0 && visitCount > 0) fail(); | ||
| 168 | for (index = 0; index < matched.length; index += 1) { | 177 | for (index = 0; index < matched.length; index += 1) { |
| 169 | visit = matched[index]; | 178 | visit = matched[index]; |
| 170 | if (visit.hasExpected) realtimeCount += 1; | 179 | if (visit.hasExpected) realtimeCount += 1; |
modified
packages/companion/test/prim-normalization.test.js +62 -2 | @@ -45,6 +45,23 @@ function snapshot(freshness, entries, sourceUpdatedAt) { | ||
| 45 | 45 | }; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | +function quayPayload(visitList) { | |
| 49 | + return { Siri: { ServiceDelivery: { ResponseTimestamp: "2026-01-15T08:30:00Z", | |
| 50 | + StopMonitoringDelivery: [{ ResponseTimestamp: "2026-01-15T08:30:00Z", MonitoredStopVisit: visitList }] } } }; | |
| 51 | +} | |
| 52 | + | |
| 53 | +function quayVisit(monitoringRef, lineRef, destinationRef, expectedIso) { | |
| 54 | + return { | |
| 55 | + RecordedAtTime: "2026-01-15T08:29:00Z", | |
| 56 | + MonitoringRef: { value: monitoringRef }, | |
| 57 | + MonitoredVehicleJourney: { | |
| 58 | + LineRef: { value: lineRef }, | |
| 59 | + DestinationRef: { value: destinationRef }, | |
| 60 | + MonitoredCall: { ExpectedDepartureTime: expectedIso, DepartureStatus: "ontime" } | |
| 61 | + } | |
| 62 | + }; | |
| 63 | +} | |
| 64 | + | |
| 48 | 65 | function visits(payload) { |
| 49 | 66 | return payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit; |
| 50 | 67 | } |
| @@ -171,14 +188,57 @@ test("exact terminal routing ignores Retour, blank and absent SIRI direction lab | ||
| 171 | 188 | opposite.MonitoredVehicleJourney.DestinationRef = { value: "STIF:StopPoint:Q:99999:" }; |
| 172 | 189 | opposite.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime = "2026-01-15T08:31:00Z"; |
| 173 | 190 | visits(payload).push(opposite); |
| 191 | + // Quay refs pool sibling destinations after the exact matches. | |
| 174 | 192 | assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), |
| 175 | - snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00")])); | |
| 193 | + snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00"), departure("08:31:00", 1, "ON_TIME", "08:33:00")], | |
| 194 | + Date.parse("2026-01-15T12:00:00Z") / 1000)); | |
| 176 | 195 | journey.DestinationRef = opposite.MonitoredVehicleJourney.DestinationRef; |
| 177 | - assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); }); | |
| 196 | + // Zero exact matches at a quay ref still pools instead of failing. | |
| 197 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | |
| 198 | + snapshot("REALTIME", [departure("08:31:00", 1, "ON_TIME", "08:33:00", 3), departure("08:34:00", 4, "ON_TIME", "08:33:00")], | |
| 199 | + Date.parse("2026-01-15T12:00:00Z") / 1000)); | |
| 178 | 200 | delete journey.DestinationRef; |
| 179 | 201 | assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); }); |
| 180 | 202 | }); |
| 181 | 203 | |
| 204 | +test("quay monitoring pools sibling destinations after exact matches", function () { | |
| 205 | + var quay = "STIF:StopPoint:Q:25433:"; | |
| 206 | + var line = "STIF:Line::C01246:"; | |
| 207 | + var refs = { | |
| 208 | + monitoringRef: quay, | |
| 209 | + lineRef: line, | |
| 210 | + destinationRef: "STIF:StopPoint:Q:493344:" | |
| 211 | + }; | |
| 212 | + var payload = quayPayload([ | |
| 213 | + quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:42:00Z"), | |
| 214 | + quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:50:00Z"), | |
| 215 | + quayVisit(quay, line, refs.destinationRef, "2026-01-15T08:35:00Z"), | |
| 216 | + quayVisit(quay, line, refs.destinationRef, "2026-01-15T09:00:00Z"), | |
| 217 | + quayVisit("STIF:StopPoint:Q:99999:", line, refs.destinationRef, "2026-01-15T08:36:00Z") | |
| 218 | + ]); | |
| 219 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | |
| 220 | + snapshot("REALTIME", [ | |
| 221 | + departure("08:35:00", 5, "ON_TIME", undefined, 25), | |
| 222 | + departure("09:00:00", 30, "ON_TIME"), | |
| 223 | + departure("08:42:00", 12, "ON_TIME", undefined, 8), | |
| 224 | + departure("08:50:00", 20, "ON_TIME") | |
| 225 | + ])); | |
| 226 | +}); | |
| 227 | + | |
| 228 | +test("area monitoring stays strict and yields an empty snapshot instead of failing", function () { | |
| 229 | + var refs = { | |
| 230 | + monitoringRef: "STIF:StopArea:SP:4001:", | |
| 231 | + lineRef: "STIF:Line::C005:", | |
| 232 | + destinationRef: "STIF:StopArea:SP:43219:" | |
| 233 | + }; | |
| 234 | + var payload = quayPayload([ | |
| 235 | + quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:99999:", "2026-01-15T08:36:00Z"), | |
| 236 | + quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:98888:", "2026-01-15T08:40:00Z") | |
| 237 | + ]); | |
| 238 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | |
| 239 | + snapshot("SCHEDULED", [])); | |
| 240 | +}); | |
| 241 | + | |
| 182 | 242 | test("explicit offsets are independent of local timezone and malformed timestamps fail closed", function () { |
| 183 | 243 | var payload = fixture("metro"); |
| 184 | 244 | var call = visits(payload)[0].MonitoredVehicleJourney.MonitoredCall; |
| @@ -45,6 +45,23 @@ function snapshot(freshness, entries, sourceUpdatedAt) { | |||
| 45 | }; | 45 | }; |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | +function quayPayload(visitList) { | ||
| 49 | + return { Siri: { ServiceDelivery: { ResponseTimestamp: "2026-01-15T08:30:00Z", | ||
| 50 | + StopMonitoringDelivery: [{ ResponseTimestamp: "2026-01-15T08:30:00Z", MonitoredStopVisit: visitList }] } } }; | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +function quayVisit(monitoringRef, lineRef, destinationRef, expectedIso) { | ||
| 54 | + return { | ||
| 55 | + RecordedAtTime: "2026-01-15T08:29:00Z", | ||
| 56 | + MonitoringRef: { value: monitoringRef }, | ||
| 57 | + MonitoredVehicleJourney: { | ||
| 58 | + LineRef: { value: lineRef }, | ||
| 59 | + DestinationRef: { value: destinationRef }, | ||
| 60 | + MonitoredCall: { ExpectedDepartureTime: expectedIso, DepartureStatus: "ontime" } | ||
| 61 | + } | ||
| 62 | + }; | ||
| 63 | +} | ||
| 64 | + | ||
| 48 | function visits(payload) { | 65 | function visits(payload) { |
| 49 | return payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit; | 66 | return payload.Siri.ServiceDelivery.StopMonitoringDelivery[0].MonitoredStopVisit; |
| 50 | } | 67 | } |
| @@ -171,14 +188,57 @@ test("exact terminal routing ignores Retour, blank and absent SIRI direction lab | |||
| 171 | opposite.MonitoredVehicleJourney.DestinationRef = { value: "STIF:StopPoint:Q:99999:" }; | 188 | opposite.MonitoredVehicleJourney.DestinationRef = { value: "STIF:StopPoint:Q:99999:" }; |
| 172 | opposite.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime = "2026-01-15T08:31:00Z"; | 189 | opposite.MonitoredVehicleJourney.MonitoredCall.ExpectedDepartureTime = "2026-01-15T08:31:00Z"; |
| 173 | visits(payload).push(opposite); | 190 | visits(payload).push(opposite); |
| 191 | + // Quay refs pool sibling destinations after the exact matches. | ||
| 174 | assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | 192 | assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), |
| 175 | - snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00")])); | 193 | + snapshot("REALTIME", [departure("08:34:00", 4, "ON_TIME", "08:33:00"), departure("08:31:00", 1, "ON_TIME", "08:33:00")], |
| 194 | + Date.parse("2026-01-15T12:00:00Z") / 1000)); | ||
| 176 | journey.DestinationRef = opposite.MonitoredVehicleJourney.DestinationRef; | 195 | journey.DestinationRef = opposite.MonitoredVehicleJourney.DestinationRef; |
| 177 | - assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); }); | 196 | + // Zero exact matches at a quay ref still pools instead of failing. |
| 197 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | ||
| 198 | + snapshot("REALTIME", [departure("08:31:00", 1, "ON_TIME", "08:33:00", 3), departure("08:34:00", 4, "ON_TIME", "08:33:00")], | ||
| 199 | + Date.parse("2026-01-15T12:00:00Z") / 1000)); | ||
| 178 | delete journey.DestinationRef; | 200 | delete journey.DestinationRef; |
| 179 | assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); }); | 201 | assert.throws(function () { departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT); }); |
| 180 | }); | 202 | }); |
| 181 | 203 | ||
| 204 | +test("quay monitoring pools sibling destinations after exact matches", function () { | ||
| 205 | + var quay = "STIF:StopPoint:Q:25433:"; | ||
| 206 | + var line = "STIF:Line::C01246:"; | ||
| 207 | + var refs = { | ||
| 208 | + monitoringRef: quay, | ||
| 209 | + lineRef: line, | ||
| 210 | + destinationRef: "STIF:StopPoint:Q:493344:" | ||
| 211 | + }; | ||
| 212 | + var payload = quayPayload([ | ||
| 213 | + quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:42:00Z"), | ||
| 214 | + quayVisit(quay, line, "STIF:StopPoint:Q:2181:", "2026-01-15T08:50:00Z"), | ||
| 215 | + quayVisit(quay, line, refs.destinationRef, "2026-01-15T08:35:00Z"), | ||
| 216 | + quayVisit(quay, line, refs.destinationRef, "2026-01-15T09:00:00Z"), | ||
| 217 | + quayVisit("STIF:StopPoint:Q:99999:", line, refs.destinationRef, "2026-01-15T08:36:00Z") | ||
| 218 | + ]); | ||
| 219 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | ||
| 220 | + snapshot("REALTIME", [ | ||
| 221 | + departure("08:35:00", 5, "ON_TIME", undefined, 25), | ||
| 222 | + departure("09:00:00", 30, "ON_TIME"), | ||
| 223 | + departure("08:42:00", 12, "ON_TIME", undefined, 8), | ||
| 224 | + departure("08:50:00", 20, "ON_TIME") | ||
| 225 | + ])); | ||
| 226 | +}); | ||
| 227 | + | ||
| 228 | +test("area monitoring stays strict and yields an empty snapshot instead of failing", function () { | ||
| 229 | + var refs = { | ||
| 230 | + monitoringRef: "STIF:StopArea:SP:4001:", | ||
| 231 | + lineRef: "STIF:Line::C005:", | ||
| 232 | + destinationRef: "STIF:StopArea:SP:43219:" | ||
| 233 | + }; | ||
| 234 | + var payload = quayPayload([ | ||
| 235 | + quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:99999:", "2026-01-15T08:36:00Z"), | ||
| 236 | + quayVisit(refs.monitoringRef, refs.lineRef, "STIF:StopArea:SP:98888:", "2026-01-15T08:40:00Z") | ||
| 237 | + ]); | ||
| 238 | + assert.deepEqual(departures.normalizePrimDepartureResponse(payload, refs, FETCHED_AT), | ||
| 239 | + snapshot("SCHEDULED", [])); | ||
| 240 | +}); | ||
| 241 | + | ||
| 182 | test("explicit offsets are independent of local timezone and malformed timestamps fail closed", function () { | 242 | test("explicit offsets are independent of local timezone and malformed timestamps fail closed", function () { |
| 183 | var payload = fixture("metro"); | 243 | var payload = fixture("metro"); |
| 184 | var call = visits(payload)[0].MonitoredVehicleJourney.MonitoredCall; | 244 | var call = visits(payload)[0].MonitoredVehicleJourney.MonitoredCall; |
modified
packages/config-page/src/config-core.js +2 -0 | @@ -121,6 +121,7 @@ export const COPY = { | ||
| 121 | 121 | favoriteRename: "Rename", |
| 122 | 122 | favoriteRemove: "Remove", |
| 123 | 123 | favoriteUnresolved: "Not found in the catalog anymore: remove it, then search for the stop again.", |
| 124 | + siblingQuay: "Shares this quay and direction: passages are pooled and each favorite prioritizes its own destination.", | |
| 124 | 125 | renamePrompt: "Favorite name", |
| 125 | 126 | save: "Save settings", |
| 126 | 127 | aboutOpen: "About", |
| @@ -185,6 +186,7 @@ export const COPY = { | ||
| 185 | 186 | favoriteRemove: "Supprimer", |
| 186 | 187 | renamePrompt: "Nom du favori", |
| 187 | 188 | favoriteUnresolved: "Introuvable dans le catalogue : supprimez-le, puis recherchez à nouveau l’arrêt.", |
| 189 | + siblingQuay: "Partage ce quai et ce sens : les passages sont mutualisés et chaque favori priorise sa destination.", | |
| 188 | 190 | save: "Enregistrer les réglages", |
| 189 | 191 | aboutOpen: "À propos", |
| 190 | 192 | aboutBack: "Retour aux réglages", |
| @@ -121,6 +121,7 @@ export const COPY = { | |||
| 121 | favoriteRename: "Rename", | 121 | favoriteRename: "Rename", |
| 122 | favoriteRemove: "Remove", | 122 | favoriteRemove: "Remove", |
| 123 | favoriteUnresolved: "Not found in the catalog anymore: remove it, then search for the stop again.", | 123 | favoriteUnresolved: "Not found in the catalog anymore: remove it, then search for the stop again.", |
| 124 | + siblingQuay: "Shares this quay and direction: passages are pooled and each favorite prioritizes its own destination.", | ||
| 124 | renamePrompt: "Favorite name", | 125 | renamePrompt: "Favorite name", |
| 125 | save: "Save settings", | 126 | save: "Save settings", |
| 126 | aboutOpen: "About", | 127 | aboutOpen: "About", |
| @@ -185,6 +186,7 @@ export const COPY = { | |||
| 185 | favoriteRemove: "Supprimer", | 186 | favoriteRemove: "Supprimer", |
| 186 | renamePrompt: "Nom du favori", | 187 | renamePrompt: "Nom du favori", |
| 187 | favoriteUnresolved: "Introuvable dans le catalogue : supprimez-le, puis recherchez à nouveau l’arrêt.", | 188 | favoriteUnresolved: "Introuvable dans le catalogue : supprimez-le, puis recherchez à nouveau l’arrêt.", |
| 189 | + siblingQuay: "Partage ce quai et ce sens : les passages sont mutualisés et chaque favori priorise sa destination.", | ||
| 188 | save: "Enregistrer les réglages", | 190 | save: "Enregistrer les réglages", |
| 189 | aboutOpen: "À propos", | 191 | aboutOpen: "À propos", |
| 190 | aboutBack: "Retour aux réglages", | 192 | aboutBack: "Retour aux réglages", |
modified
packages/config-page/src/config-page.js +12 -0 | @@ -229,6 +229,12 @@ function renderFavorites() { | ||
| 229 | 229 | const available = LIMITS.favorites - used; |
| 230 | 230 | setText(elements.favorites_count, |
| 231 | 231 | `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`); |
| 232 | + const sharedQuays = new Map(); | |
| 233 | + state.favorites.forEach((favorite) => { | |
| 234 | + if (!Object.hasOwn(favorite, "routing")) return; | |
| 235 | + const quay = favorite.routing.monitoringRef; | |
| 236 | + sharedQuays.set(quay, (sharedQuays.get(quay) ?? 0) + 1); | |
| 237 | + }); | |
| 232 | 238 | |
| 233 | 239 | state.favorites.forEach((favorite, index) => { |
| 234 | 240 | const item = document.createElement("li"); |
| @@ -253,6 +259,12 @@ function renderFavorites() { | ||
| 253 | 259 | unresolved.textContent = copy.favoriteUnresolved; |
| 254 | 260 | labels.append(unresolved); |
| 255 | 261 | } |
| 262 | + if (Object.hasOwn(favorite, "routing") && sharedQuays.get(favorite.routing.monitoringRef) > 1) { | |
| 263 | + const sibling = document.createElement("span"); | |
| 264 | + sibling.className = "favorite-sibling"; | |
| 265 | + sibling.textContent = copy.siblingQuay; | |
| 266 | + labels.append(sibling); | |
| 267 | + } | |
| 256 | 268 | const routeRow = document.createElement("div"); |
| 257 | 269 | routeRow.className = "favorite-route"; |
| 258 | 270 | routeRow.append(route, labels); |
| @@ -229,6 +229,12 @@ function renderFavorites() { | |||
| 229 | const available = LIMITS.favorites - used; | 229 | const available = LIMITS.favorites - used; |
| 230 | setText(elements.favorites_count, | 230 | setText(elements.favorites_count, |
| 231 | `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`); | 231 | `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`); |
| 232 | + const sharedQuays = new Map(); | ||
| 233 | + state.favorites.forEach((favorite) => { | ||
| 234 | + if (!Object.hasOwn(favorite, "routing")) return; | ||
| 235 | + const quay = favorite.routing.monitoringRef; | ||
| 236 | + sharedQuays.set(quay, (sharedQuays.get(quay) ?? 0) + 1); | ||
| 237 | + }); | ||
| 232 | 238 | ||
| 233 | state.favorites.forEach((favorite, index) => { | 239 | state.favorites.forEach((favorite, index) => { |
| 234 | const item = document.createElement("li"); | 240 | const item = document.createElement("li"); |
| @@ -253,6 +259,12 @@ function renderFavorites() { | |||
| 253 | unresolved.textContent = copy.favoriteUnresolved; | 259 | unresolved.textContent = copy.favoriteUnresolved; |
| 254 | labels.append(unresolved); | 260 | labels.append(unresolved); |
| 255 | } | 261 | } |
| 262 | + if (Object.hasOwn(favorite, "routing") && sharedQuays.get(favorite.routing.monitoringRef) > 1) { | ||
| 263 | + const sibling = document.createElement("span"); | ||
| 264 | + sibling.className = "favorite-sibling"; | ||
| 265 | + sibling.textContent = copy.siblingQuay; | ||
| 266 | + labels.append(sibling); | ||
| 267 | + } | ||
| 256 | const routeRow = document.createElement("div"); | 268 | const routeRow = document.createElement("div"); |
| 257 | routeRow.className = "favorite-route"; | 269 | routeRow.className = "favorite-route"; |
| 258 | routeRow.append(route, labels); | 270 | routeRow.append(route, labels); |
modified
packages/config-page/styles/config-page.css +1 -0 | @@ -221,6 +221,7 @@ button:disabled { cursor: default; opacity: 0.35; } | ||
| 221 | 221 | .route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); } |
| 222 | 222 | .route-stop::before { background: var(--muted); } |
| 223 | 223 | .unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; } |
| 224 | +.favorite-sibling { display: block; margin-top: 0.25rem; color: var(--muted); font-size: 0.75rem; line-height: 1.5; } | |
| 224 | 225 | .favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; } |
| 225 | 226 | .favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; } |
| 226 | 227 | .favorite-actions { display: flex; } |
| @@ -221,6 +221,7 @@ button:disabled { cursor: default; opacity: 0.35; } | |||
| 221 | .route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); } | 221 | .route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); } |
| 222 | .route-stop::before { background: var(--muted); } | 222 | .route-stop::before { background: var(--muted); } |
| 223 | .unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; } | 223 | .unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; } |
| 224 | +.favorite-sibling { display: block; margin-top: 0.25rem; color: var(--muted); font-size: 0.75rem; line-height: 1.5; } | ||
| 224 | .favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; } | 225 | .favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; } |
| 225 | .favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; } | 226 | .favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; } |
| 226 | .favorite-actions { display: flex; } | 227 | .favorite-actions { display: flex; } |