+
{(["Alewife", "Ashmont", "Braintree"] as const).map((branch) => (
{
{isOverflowing && (
-
+
+
+
)}
>
);
From b7c5061e18841b7781ce832b933971aafe574896 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:26:53 -0400
Subject: [PATCH 06/28] test: add specs for picker visiblity and coloring
---
.../ladderPageShared/branchPicker.tsx | 4 +-
js/components/ladderPageShared/ladderPage.tsx | 1 +
.../ladderPageShared/branchPicker.test.tsx | 157 ++++++++++++++++++
.../ladderPageShared/ladder.test.tsx | 109 ++++++++++++
.../ladderPageShared/ladderPage.test.tsx | 73 +++++++-
5 files changed, 341 insertions(+), 3 deletions(-)
create mode 100644 js/test/components/ladderPageShared/branchPicker.test.tsx
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index 767eb3ea..1b4cc50f 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -40,8 +40,8 @@ const BranchButton = ({
className={`flex flex-col justify-center items-center grow ${buttonBg} rounded-t`}
onClick={onClick}
>
-
{branch}
-
+
{branch}
+
);
};
diff --git a/js/components/ladderPageShared/ladderPage.tsx b/js/components/ladderPageShared/ladderPage.tsx
index dd9d1a58..fd548f22 100644
--- a/js/components/ladderPageShared/ladderPage.tsx
+++ b/js/components/ladderPageShared/ladderPage.tsx
@@ -112,6 +112,7 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
: null}
= {
+ Alewife: "bg-glides-gray-400",
+ Ashmont: "bg-heavy-rail-ashmont",
+ Braintree: "bg-heavy-rail-braintree",
+};
+
+const inactiveDotText: Record
= {
+ Alewife: "text-glides-gray-400",
+ Ashmont: "text-heavy-rail-ashmont",
+ Braintree: "text-heavy-rail-braintree",
+};
+
+describe("BranchPicker", () => {
+ test("renders all three branch buttons", () => {
+ const view = render(
+ ,
+ );
+ expect(view.getByRole("button", { name: /Alewife/i })).toBeInTheDocument();
+ expect(view.getByRole("button", { name: /Ashmont/i })).toBeInTheDocument();
+ expect(
+ view.getByRole("button", { name: /Braintree/i }),
+ ).toBeInTheDocument();
+ });
+
+ describe.each(branches)("when %s is selected", (activeBranch) => {
+ test("active button has branch-specific background", () => {
+ const view = render(
+ ,
+ );
+ expect(
+ view.getByRole("button", { name: new RegExp(activeBranch, "i") }),
+ ).toHaveClass(activeBg[activeBranch]);
+ });
+
+ test("inactive buttons have default background", () => {
+ const view = render(
+ ,
+ );
+ branches
+ .filter((b) => b !== activeBranch)
+ .forEach((branch) => {
+ expect(
+ view.getByRole("button", { name: new RegExp(branch, "i") }),
+ ).toHaveClass(defaultBg);
+ });
+ });
+
+ test("active button label has dark-blue text", () => {
+ const view = render(
+ ,
+ );
+ const button = view.getByRole("button", {
+ name: new RegExp(activeBranch, "i"),
+ });
+ expect(within(button).getByTestId("branch-label")).toHaveClass(
+ activeText,
+ );
+ });
+
+ test("inactive button labels have white text", () => {
+ const view = render(
+ ,
+ );
+ branches
+ .filter((b) => b !== activeBranch)
+ .forEach((branch) => {
+ const button = view.getByRole("button", {
+ name: new RegExp(branch, "i"),
+ });
+ expect(within(button).getByTestId("branch-label")).toHaveClass(
+ "text-white",
+ );
+ });
+ });
+
+ test("inactive button dots have branch-specific color", () => {
+ const view = render(
+ ,
+ );
+ branches
+ .filter((b) => b !== activeBranch)
+ .forEach((branch) => {
+ const button = view.getByRole("button", {
+ name: new RegExp(branch, "i"),
+ });
+ expect(within(button).getByTestId("branch-dot")).toHaveClass(
+ inactiveDotText[branch],
+ );
+ });
+ });
+
+ test("active button dot has dark-blue text", () => {
+ const view = render(
+ ,
+ );
+ const button = view.getByRole("button", {
+ name: new RegExp(activeBranch, "i"),
+ });
+ expect(within(button).getByTestId("branch-dot")).toHaveClass(activeText);
+ });
+ });
+
+ describe("clicking buttons", () => {
+ test.each(branches)(
+ "clicking %s calls setBranchPickerSelection with correct value",
+ async (branch) => {
+ const mockSet = jest.fn();
+ const user = userEvent.setup();
+ const view = render(
+ ,
+ );
+ await user.click(
+ view.getByRole("button", { name: new RegExp(branch, "i") }),
+ );
+ expect(mockSet).toHaveBeenCalledWith(branch);
+ },
+ );
+ });
+});
diff --git a/js/test/components/ladderPageShared/ladder.test.tsx b/js/test/components/ladderPageShared/ladder.test.tsx
index 4237e437..1d95bbc5 100644
--- a/js/test/components/ladderPageShared/ladder.test.tsx
+++ b/js/test/components/ladderPageShared/ladder.test.tsx
@@ -1,12 +1,15 @@
import { Ladders } from "../../../components/ladderPageShared/ladder";
+import { ORBIT_RL_TRAINSTARTERS } from "../../../groups";
import { useVehicles } from "../../../hooks/useVehicles";
import { StopStatus } from "../../../models/vehiclePosition";
+import { getMetaContent, MetaDataKey } from "../../../util/metadata";
import {
tripUpdateFactory,
vehicleFactory,
vehiclePositionFactory,
} from "../../helpers/factory";
import { render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
jest.mock("../../../hooks/useVehicles", () => ({
__esModule: true,
@@ -14,6 +17,13 @@ jest.mock("../../../hooks/useVehicles", () => ({
}));
const mockUseVehicles = useVehicles as jest.MockedFunction;
+jest.mock("../../../util/metadata", () => ({
+ getMetaContent: jest.fn(),
+}));
+const mockGetMetaContent = getMetaContent as jest.MockedFunction<
+ typeof getMetaContent
+>;
+
// Vehicle IDs get used as React component keys, so make sure we
// use a different ID for each mock vehicle, or render will complain
const nextVehicleId = (() => {
@@ -358,4 +368,103 @@ describe("Ladder", () => {
expect(view.getByText("1888")).not.toHaveClass("border-[3px]");
});
});
+
+ describe("branch selection on train click", () => {
+ beforeAll(() => {
+ mockGetMetaContent.mockImplementation((field: MetaDataKey) => {
+ if (field === "userGroups") return ORBIT_RL_TRAINSTARTERS;
+ return null;
+ });
+ });
+
+ test("clicking a train on the Ashmont ladder calls setBranchPickerSelection with Ashmont", async () => {
+ const mockSetBranch = jest.fn();
+ mockUseVehicles.mockReturnValue([
+ vehicleFactory.build({
+ vehiclePosition: vehiclePositionFactory.build({
+ vehicleId: nextVehicleId(),
+ label: "1999",
+ stationId: "place-asmnl",
+ stopId: "70094",
+ stopStatus: StopStatus.StoppedAt,
+ position: null,
+ }),
+ }),
+ ]);
+
+ const user = userEvent.setup();
+ const view = render(
+ ,
+ );
+
+ await user.click(view.getByRole("button", { name: "1999" }));
+ expect(mockSetBranch).toHaveBeenCalledWith("Ashmont");
+ });
+
+ test("clicking a train on the Braintree ladder calls setBranchPickerSelection with Braintree", async () => {
+ const mockSetBranch = jest.fn();
+ mockUseVehicles.mockReturnValue([
+ vehicleFactory.build({
+ vehiclePosition: vehiclePositionFactory.build({
+ vehicleId: nextVehicleId(),
+ label: "2001",
+ stationId: "place-brntn",
+ stopId: "70105",
+ stopStatus: StopStatus.StoppedAt,
+ position: null,
+ }),
+ }),
+ ]);
+
+ const user = userEvent.setup();
+ const view = render(
+ ,
+ );
+
+ await user.click(view.getByRole("button", { name: "2001" }));
+ expect(mockSetBranch).toHaveBeenCalledWith("Braintree");
+ });
+
+ test("clicking a train on the Alewife trunk ladder calls setBranchPickerSelection with Alewife", async () => {
+ const mockSetBranch = jest.fn();
+ mockUseVehicles.mockReturnValue([
+ vehicleFactory.build({
+ vehiclePosition: vehiclePositionFactory.build({
+ vehicleId: nextVehicleId(),
+ label: "1888",
+ stationId: "place-davis",
+ stopId: "70064",
+ stopStatus: StopStatus.StoppedAt,
+ position: null,
+ }),
+ }),
+ ]);
+
+ const user = userEvent.setup();
+ const view = render(
+ ,
+ );
+
+ await user.click(view.getByRole("button", { name: "1888" }));
+ expect(mockSetBranch).toHaveBeenCalledWith("Alewife");
+ });
+ });
});
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index d671f119..a2040198 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -4,7 +4,7 @@ import { useVehicles } from "../../../hooks/useVehicles";
import { trackSideBarOpened } from "../../../telemetry/trackingEvents";
import { getMetaContent, MetaDataKey } from "../../../util/metadata";
import { vehicleFactory, vehiclePositionFactory } from "../../helpers/factory";
-import { render } from "@testing-library/react";
+import { act, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
jest.mock("../../../hooks/useVehicles", () => ({
@@ -316,3 +316,74 @@ describe("LadderPage SideBar", () => {
});
});
});
+
+describe("LadderPage BranchPicker visibility", () => {
+ beforeEach(() => {
+ mockUseVehicles.mockReturnValue([vehicleFactory.build()]);
+ mockGetMetaContent.mockReturnValue(null);
+ });
+
+ test("BranchPicker is hidden by default (no overflow in jsdom)", () => {
+ const view = render();
+ // Buttons named by branch only exist in BranchPicker (station names are in , not
);
};
From b340983e0f481a387d8448bbfad2371e1b800dbf Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Tue, 25 Aug 2026 10:21:37 -0400
Subject: [PATCH 08/28] fix: address typescript errors
---
js/components/ladderPageShared/ladder.tsx | 6 ++----
.../ladderPageShared/ladder.test.tsx | 21 ++++++++++++-------
.../ladderPageShared/ladderPage.test.tsx | 4 +---
3 files changed, 17 insertions(+), 14 deletions(-)
diff --git a/js/components/ladderPageShared/ladder.tsx b/js/components/ladderPageShared/ladder.tsx
index e1ea104a..eecc73c5 100644
--- a/js/components/ladderPageShared/ladder.tsx
+++ b/js/components/ladderPageShared/ladder.tsx
@@ -3,8 +3,8 @@ import { RouteId } from "../../models/common";
import { Vehicle } from "../../models/vehicle";
import { StopStatus } from "../../models/vehiclePosition";
import { className } from "../../util/dom";
-import { height } from "./height";
import { BranchPickerSelection } from "./branchPicker";
+import { height } from "./height";
import { SideBarSelection } from "./sidebar";
import { avoidLabelOverlaps, Train } from "./train";
import {
@@ -99,9 +99,7 @@ const TrainsAndStations = ({
setBranchPickerSelection: (selection: BranchPickerSelection) => void;
}): ReactElement => {
const branch = branchForLadder(ladderConfig);
- const setSideBarSelectionAndBranch = (
- selection: SideBarSelection | null,
- ) => {
+ const setSideBarSelectionAndBranch = (selection: SideBarSelection | null) => {
if (selection !== null) {
setBranchPickerSelection(branch);
}
diff --git a/js/test/components/ladderPageShared/ladder.test.tsx b/js/test/components/ladderPageShared/ladder.test.tsx
index 1d95bbc5..fd9f38d1 100644
--- a/js/test/components/ladderPageShared/ladder.test.tsx
+++ b/js/test/components/ladderPageShared/ladder.test.tsx
@@ -39,7 +39,8 @@ describe("Ladder", () => {
const view = render(
,
@@ -85,7 +86,8 @@ describe("Ladder", () => {
const view = render(
,
@@ -148,7 +150,8 @@ describe("Ladder", () => {
const view = render(
,
@@ -191,7 +194,8 @@ describe("Ladder", () => {
const view = render(
,
@@ -277,7 +281,8 @@ describe("Ladder", () => {
const view = render(
,
@@ -327,7 +332,8 @@ describe("Ladder", () => {
const view = render(
{
const view = render(
,
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index a2040198..6e1db8f2 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -382,8 +382,6 @@ describe("LadderPage BranchPicker visibility", () => {
window.dispatchEvent(new Event("resize"));
});
- expect(
- queryByRole("button", { name: /Alewife/i }),
- ).not.toBeInTheDocument();
+ expect(queryByRole("button", { name: /Alewife/i })).not.toBeInTheDocument();
});
});
From 3cce00037976e7a3833fd096cf59089e9fb71417 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:39:20 -0400
Subject: [PATCH 09/28] build: run npm update rail-tech-ui
---
package-lock.json | 159 ++++++++++++++++++++--------------------------
1 file changed, 69 insertions(+), 90 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 4f5ddfff..a2e0c9c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -633,20 +633,29 @@
}
},
"node_modules/@bundled-es-modules/glob": {
- "version": "13.0.6",
- "resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-13.0.6.tgz",
- "integrity": "sha512-x9nR2e1pt8LF0yLPC6yz/aUoiN7qJJwZ1znLxIXCxGyH+8BI+yO/sklBdn1+QbUyWXQBM+CjfZz3IhqtgIoDVg==",
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-aD2nUfnJMa4hzRXhCRXKtIeOiCyGzJwJ/3mfHQeFxPANzpmQZRzv8lgWabYt1VtXkI6mvBPA+x8K5gB/lzPhIQ==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"events": "^3.3.0",
- "glob": "^13.0.6",
+ "glob": "^11.1.0",
"path": "^0.12.7",
"stream": "^0.0.3",
"string_decoder": "^1.3.0",
"url": "^0.11.4"
}
},
+ "node_modules/@bundled-es-modules/glob/node_modules/@isaacs/cliui": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+ "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bundled-es-modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -669,17 +678,39 @@
}
},
"node_modules/@bundled-es-modules/glob/node_modules/glob": {
- "version": "13.0.6",
- "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
- "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "BlueOak-1.0.0",
"dependencies": {
- "minimatch": "^10.2.2",
- "minipass": "^7.1.3",
- "path-scurry": "^2.0.2"
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@bundled-es-modules/glob/node_modules/jackspeak": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
+ "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^9.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -2698,16 +2729,6 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
- "node_modules/@remix-run/router": {
- "version": "1.23.3",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
- "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/@restart/hooks": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.0.tgz",
@@ -3839,9 +3860,9 @@
]
},
"node_modules/@zip.js/zip.js": {
- "version": "2.8.36",
- "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.36.tgz",
- "integrity": "sha512-3CT63BXqYh781nZhNVOktg71oM1496BZhBHDjP5aOZTDmsL2nLkldbLDJFatFJt00j94TFnsZu2pWNijL4Eorw==",
+ "version": "2.8.60",
+ "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.60.tgz",
+ "integrity": "sha512-pULv0waMlnKAUUxrsuAOa0ADONGRuhdHORtuXVgClNlScDB5YjImCV8DZrE1SicaROyE6MwGkH8CLJ+o4Nx07g==",
"license": "BSD-3-Clause",
"engines": {
"bun": ">=0.7.0",
@@ -4706,12 +4727,6 @@
"integrity": "sha512-OXAPGFRNeLFnUfqDtloYdxkwsJoIdXe28+bjbpJiPqyei2HPa3VHmMCWa0Qe62+U4Ftf9Hj7hRssOkxz7WiWbg==",
"license": "MIT"
},
- "node_modules/colorjs.io": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
- "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
- "license": "MIT"
- },
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -5959,11 +5974,12 @@
}
},
"node_modules/foreground-child": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
- "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
"dependencies": {
- "cross-spawn": "^7.0.0",
+ "cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
@@ -9554,22 +9570,21 @@
]
},
"node_modules/rail-tech-ui": {
- "version": "1.0.0",
- "resolved": "git+ssh://git@github.com/mbta/rail-tech-ui.git#95690f3a12154fb0334eb9737bfd8887c89498bd",
+ "version": "2.0.0",
+ "resolved": "git+ssh://git@github.com/mbta/rail-tech-ui.git#310f288b43736613d9e83152d828029407287e1c",
"license": "MIT",
"dependencies": {
- "@tailwindcss/container-queries": "^0.1.1",
- "luxon": "^3.7.2",
- "style-dictionary": "^5.1.0",
- "style-dictionary-utils": "^4.1.1",
- "tailwindcss": "^3.4.19",
+ "@tailwindcss/container-queries": "0.1.1",
+ "luxon": "3.7.2",
+ "style-dictionary": "5.1.0",
+ "style-dictionary-utils": "4.1.1",
+ "tailwindcss": "3.4.19",
"typescript": "5.7.3",
"zod": "4.4.3"
},
"peerDependencies": {
- "react": "^19.2.7",
- "react-dom": "^19.2.7",
- "react-router-dom": "^6.30.4"
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
}
},
"node_modules/react": {
@@ -9621,40 +9636,6 @@
}
}
},
- "node_modules/react-router-dom": {
- "version": "6.30.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
- "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@remix-run/router": "1.23.3",
- "react-router": "6.30.4"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "react": ">=16.8",
- "react-dom": ">=16.8"
- }
- },
- "node_modules/react-router-dom/node_modules/react-router": {
- "version": "6.30.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
- "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@remix-run/router": "1.23.3"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "react": ">=16.8"
- }
- },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -10400,18 +10381,17 @@
}
},
"node_modules/style-dictionary": {
- "version": "5.5.1",
- "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-5.5.1.tgz",
- "integrity": "sha512-+fwPuRKopBLNolzefOHrAtCQomJqj0HVbH0XWBwXEQR3CV+i2pkNY5yCSPPv0qNHc3ibnbuzF4Qui3tZehexIw==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-5.1.0.tgz",
+ "integrity": "sha512-HsCPJAqJIjQaxhHmiHSEqM96TAC/+6B6aLYeXAbcfLcrooKClKLaWl5T5vqJNNfqmJlpoRP9k0dkcqfAk0q4Kg==",
"license": "Apache-2.0",
"dependencies": {
- "@bundled-es-modules/deepmerge": "^4.3.2",
- "@bundled-es-modules/glob": "^13.0.6",
- "@bundled-es-modules/memfs": "^4.17.0",
+ "@bundled-es-modules/deepmerge": "^4.3.1",
+ "@bundled-es-modules/glob": "^11.0.3",
+ "@bundled-es-modules/memfs": "^4.9.4",
"@zip.js/zip.js": "^2.7.44",
"chalk": "^5.3.0",
"change-case": "^5.3.0",
- "colorjs.io": "^0.5.2",
"commander": "^12.1.0",
"is-plain-obj": "^4.1.0",
"json5": "^2.2.2",
@@ -10427,13 +10407,12 @@
}
},
"node_modules/style-dictionary-utils": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/style-dictionary-utils/-/style-dictionary-utils-4.2.1.tgz",
- "integrity": "sha512-OSrKc5vKNgCVSrO0iS22K1N5UEM6ZNAzjPvfNtRYqllLglhd2DO3c35beXXJf+KpANbsv/AaOxpLaRQqEbz9+Q==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/style-dictionary-utils/-/style-dictionary-utils-4.1.1.tgz",
+ "integrity": "sha512-aedkXWL19/pgr7ocPqkfMD1jCJLxfN2hiI2R/esTJjJw/DRRYkw6C3yhrZzJ0LejdQGPgrDwcib0cSF0Wf3ddQ==",
"license": "MIT",
"dependencies": {
- "color2k": "^2.0.3",
- "colorjs.io": "^0.5.2"
+ "color2k": "^2.0.3"
},
"peerDependencies": {
"style-dictionary": "^4 || ^5"
From f39853f134d835f1bf5144bd462e27987b0c6e1c Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:28:44 -0400
Subject: [PATCH 10/28] style: replace css variables with tailwind values
---
js/components/ladderPageShared/branchPicker.tsx | 10 +++++++---
.../components/ladderPageShared/branchPicker.test.tsx | 10 ++++++----
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index 9dae9b65..4381f359 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,16 +1,20 @@
export type BranchPickerSelection = "Alewife" | "Ashmont" | "Braintree";
const defaultBg =
- "bg-[rgb(var(--ladder-branch-picker-background-dark))] light:bg-[rgb(var(--ladder-branch-picker-background-light))]";
+ "bg-ladder-branch-picker-inactive-bg-dark light:bg-ladder-branch-picker-inactive-bg-light";
const activeText =
- "text-[rgb(var(--ladder-branch-picker-background-dark))] light:text-[rgb(var(--ladder-branch-picker-background-light))]";
+ "text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
const branchColors: Record<
BranchPickerSelection,
{ bg: string; dotText: string }
> = {
- Alewife: { bg: "bg-glides-gray-400", dotText: "text-glides-gray-400" },
+ Alewife: {
+ bg: "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
+ dotText:
+ "text-ladder-branch-picker-alewife-dot-dark light:text-ladder-branch-picker-alewife-dot-light",
+ },
Ashmont: {
bg: "bg-heavy-rail-ashmont",
dotText: "text-heavy-rail-ashmont",
diff --git a/js/test/components/ladderPageShared/branchPicker.test.tsx b/js/test/components/ladderPageShared/branchPicker.test.tsx
index e616c64e..59d03abd 100644
--- a/js/test/components/ladderPageShared/branchPicker.test.tsx
+++ b/js/test/components/ladderPageShared/branchPicker.test.tsx
@@ -6,20 +6,22 @@ import { render, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const defaultBg =
- "bg-[rgb(var(--ladder-branch-picker-background-dark))] light:bg-[rgb(var(--ladder-branch-picker-background-light))]";
+ "bg-ladder-branch-picker-inactive-bg-dark light:bg-ladder-branch-picker-inactive-bg-light";
const activeText =
- "text-[rgb(var(--ladder-branch-picker-background-dark))] light:text-[rgb(var(--ladder-branch-picker-background-light))]";
+ "text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
const branches: BranchPickerSelection[] = ["Alewife", "Ashmont", "Braintree"];
const activeBg: Record = {
- Alewife: "bg-glides-gray-400",
+ Alewife:
+ "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
Ashmont: "bg-heavy-rail-ashmont",
Braintree: "bg-heavy-rail-braintree",
};
const inactiveDotText: Record = {
- Alewife: "text-glides-gray-400",
+ Alewife:
+ "text-ladder-branch-picker-alewife-dot-dark light:text-ladder-branch-picker-alewife-dot-light",
Ashmont: "text-heavy-rail-ashmont",
Braintree: "text-heavy-rail-braintree",
};
From 1e70866b47b9013801d8a913116afc6a3b8c3b16 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:35:18 -0400
Subject: [PATCH 11/28] feat: focus on Ashmont by default
---
js/components/ladderPageShared/ladderPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/components/ladderPageShared/ladderPage.tsx b/js/components/ladderPageShared/ladderPage.tsx
index 23a87f10..f0fe02a0 100644
--- a/js/components/ladderPageShared/ladderPage.tsx
+++ b/js/components/ladderPageShared/ladderPage.tsx
@@ -19,7 +19,7 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
const [sideBarSelection, setSideBarSelection] =
useState(null);
const [branchPickerSelection, setBranchPickerSelection] =
- useState("Alewife");
+ useState("Ashmont");
const [searchQuery, setSearchQuery] = useState("");
const [isOverflowing, setIsOverflowing] = useState(false);
const scrollContainerRef = useRef(null);
From c13dbe2d66367953741794282e6cbe1b7639d8ed Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:52:15 -0400
Subject: [PATCH 12/28] fix: show search cars field and branch picker
These items were not showing up properly after merging in main.
---
js/components/ladderPageShared/ladder.tsx | 10 ++++++++--
js/components/ladderPageShared/ladderPage.tsx | 8 ++++----
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/js/components/ladderPageShared/ladder.tsx b/js/components/ladderPageShared/ladder.tsx
index 10d86912..95cc7a42 100644
--- a/js/components/ladderPageShared/ladder.tsx
+++ b/js/components/ladderPageShared/ladder.tsx
@@ -19,7 +19,7 @@ import { Ladder } from "rail-tech-ui";
import type { VehicleSelection } from "rail-tech-ui/dist/src/components/ladderPage/types";
import { RoutePatternId } from "rail-tech-ui/dist/src/models/route";
import type { TrainLoc } from "rail-tech-ui/dist/src/models/trainLocation";
-import { ReactElement } from "react";
+import { ReactElement, Ref } from "react";
const ROUTE_PATTERN_CONFIG: Readonly<
Record>
@@ -107,12 +107,14 @@ export const Ladders = ({
setSideBarSelection,
setBranchPickerSelection,
vehicles,
+ ref,
}: {
routeId: RouteId;
sideBarSelection: SideBarSelection | null;
setSideBarSelection: (selection: SideBarSelection | null) => void;
setBranchPickerSelection: (selection: BranchPickerSelection) => void;
vehicles: Vehicle[];
+ ref?: Ref;
}): ReactElement => {
const stationLists = Stations[routeId];
const vehiclesByBranch = vehicles.reduce(
@@ -179,7 +181,11 @@ export const Ladders = ({
: null;
return (
-
+
{Array.from(vehiclesByBranch.entries()).map(
([stationList, branchVehicles], index) => (
{
useState
("Ashmont");
const [searchQuery, setSearchQuery] = useState("");
const [isOverflowing, setIsOverflowing] = useState(false);
- const scrollContainerRef = useRef(null);
+ const laddersRef = useRef(null);
const openSideBar = useCallback(
(selection: SideBarSelection | null) => {
@@ -58,7 +58,7 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
}, [onEscape]);
useEffect(() => {
- const el = scrollContainerRef.current;
+ const el = laddersRef.current;
if (!el) return;
const check = () => {
setIsOverflowing(el.scrollWidth > el.clientWidth);
@@ -113,10 +113,9 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
: null}
{
onQueryChange={onQueryChange}
/>
Date: Fri, 28 Aug 2026 11:00:21 -0400
Subject: [PATCH 13/28] chore: update test ID name to clarify target
---
.../components/ladderPageShared/ladderPage.test.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index e1eceab6..f76c3a57 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -363,10 +363,10 @@ describe("LadderPage BranchPicker visibility", () => {
test("BranchPicker is shown when scroll container overflows horizontally", () => {
const { getByTestId } = render();
- const scrollContainer = getByTestId("scroll-container");
+ const laddersScrollContainer = getByTestId("ladders-scroll-container");
// eslint-disable-next-line better-mutation/no-mutating-functions
- Object.defineProperty(scrollContainer, "scrollWidth", {
+ Object.defineProperty(laddersScrollContainer, "scrollWidth", {
get: () => 1000,
configurable: true,
});
@@ -390,11 +390,11 @@ describe("LadderPage BranchPicker visibility", () => {
test("BranchPicker is hidden again when overflow is resolved", () => {
const { getByTestId, queryByTestId } = render();
- const scrollContainer = getByTestId("scroll-container");
+ const laddersScrollContainer = getByTestId("ladders-scroll-container");
// first simulate overflow
// eslint-disable-next-line better-mutation/no-mutating-functions
- Object.defineProperty(scrollContainer, "scrollWidth", {
+ Object.defineProperty(laddersScrollContainer, "scrollWidth", {
get: () => 1000,
configurable: true,
});
@@ -404,7 +404,7 @@ describe("LadderPage BranchPicker visibility", () => {
// then resolve overflow
// eslint-disable-next-line better-mutation/no-mutating-functions
- Object.defineProperty(scrollContainer, "scrollWidth", {
+ Object.defineProperty(laddersScrollContainer, "scrollWidth", {
get: () => 0,
configurable: true,
});
From c5e3f698bf642b5b9cd9e7fc14a1eb1f8e94c8ae Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:03:19 -0400
Subject: [PATCH 14/28] style: match branch picker to background color
---
js/components/ladderPageShared/ladderPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/components/ladderPageShared/ladderPage.tsx b/js/components/ladderPageShared/ladderPage.tsx
index 5918072f..c377594c 100644
--- a/js/components/ladderPageShared/ladderPage.tsx
+++ b/js/components/ladderPageShared/ladderPage.tsx
@@ -138,7 +138,7 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
{isOverflowing && (
-
+
Date: Fri, 28 Aug 2026 14:48:00 -0400
Subject: [PATCH 15/28] style: ensure address bar does not block picker
On mobile (specifically Safari on iOS) the address bar (which is now on
the bottom of the screen) blocks the address picker.
This resolves this issue. Of note, I setting `h-screen h-dvh` as
Tailwind variables, but they seemed to conflict and it did not work.
---
css/app.css | 4 ++++
js/components/app.tsx | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/css/app.css b/css/app.css
index c98b7a89..dc1fef5d 100644
--- a/css/app.css
+++ b/css/app.css
@@ -7,3 +7,7 @@
@import "tailwindcss/utilities";
/* This file is for your main application CSS */
+.app-wrapper {
+ height: 100vh;
+ height: 100dvh;
+}
diff --git a/js/components/app.tsx b/js/components/app.tsx
index 95d7c045..9644bb89 100644
--- a/js/components/app.tsx
+++ b/js/components/app.tsx
@@ -83,7 +83,7 @@ const router = createBrowserRouter([
element: (
-
+
From b0c455693a4edfada5a515bd806a3fdcdd255934 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:43:26 -0400
Subject: [PATCH 16/28] test: remove pill specs merged from deleted ladder
---
.../ladderPageShared/ladderPage.test.tsx | 337 +-----------------
1 file changed, 1 insertion(+), 336 deletions(-)
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index f76c3a57..2c7e777d 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -5,11 +5,7 @@ import { useVehicles } from "../../../hooks/useVehicles";
import { StopStatus } from "../../../models/vehiclePosition";
import { trackSideBarOpened } from "../../../telemetry/trackingEvents";
import { getMetaContent, MetaDataKey } from "../../../util/metadata";
-import {
- tripUpdateFactory,
- vehicleFactory,
- vehiclePositionFactory,
-} from "../../helpers/factory";
+import { vehicleFactory, vehiclePositionFactory } from "../../helpers/factory";
import { act, render, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -416,30 +412,11 @@ describe("LadderPage BranchPicker visibility", () => {
});
});
-// The following Ladder suite moved here from the deleted
-// js/test/components/ladderPageShared/ladder.test.tsx (removed by main). The
-// LadderPage is now backed by rail-tech-ui's Ladder, so these render the
-// `Ladders` component directly to keep covering which trains render, pill
-// colors, and clicking a train to select its branch.
-// Vehicle IDs are used as React keys, so make sure each mock vehicle has a
-// unique id or render will warn
const nextVehicleId = (() => {
let mockVehicleId = 0;
return () => `mock-id-${mockVehicleId++}`;
})();
-const pillForLabel = (
- view: ReturnType
,
- label: string,
-): HTMLElement => {
- // closest() is used to reach the pill container (a plain div with no
- // semantic role) from the rendered car id inside it
- // eslint-disable-next-line testing-library/no-node-access
- const pill = view.getByText(label).closest(".rounded-4xl");
- expect(pill).not.toBeNull();
- return pill as HTMLElement;
-};
-
describe("Ladder", () => {
test("shows station names", () => {
mockUseVehicles.mockReturnValue([]);
@@ -459,318 +436,6 @@ describe("Ladder", () => {
expect(view.getByText("Braintree")).toBeInTheDocument();
});
- test("shows valid vehicles on the ladder", () => {
- mockUseVehicles.mockReturnValue([
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build(),
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- directionId: 0,
- label: "1888",
- cars: ["1888", "1876", "1807", "1806", "1815", "1814"],
- position: { latitude: 42.32272, longitude: -71.052925 },
- stopId: "70085",
- stopStatus: StopStatus.StoppedAt,
- }),
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1889",
- cars: ["1889", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70064",
- position: { latitude: 42.39674, longitude: -71.121815 },
- stopStatus: StopStatus.StoppedAt,
- }),
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1999",
- cars: ["1999", "1876", "1807", "1806", "1815", "1814"],
- position: null,
- stationId: null,
- stopId: null,
- }),
- }),
- ]);
-
- const view = render(
- ,
- );
-
- expect(view.getByText("1877")).toBeInTheDocument();
- expect(view.getByText("1888")).toBeInTheDocument();
- expect(view.getByText("1889")).toBeInTheDocument();
- expect(view.queryByText("1999")).not.toBeInTheDocument();
- });
-
- describe("pill colors", () => {
- test("renders gray pills for non-revenue trains", () => {
- mockUseVehicles.mockReturnValue([
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1888",
- cars: ["1888", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: "11111",
- revenue: false,
- }),
- tripUpdate: tripUpdateFactory.build({
- routePatternId: "Red-1-0",
- }),
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1889",
- cars: ["1889", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: "22222",
- revenue: false,
- }),
- tripUpdate: tripUpdateFactory.build({
- routePatternId: "Red-3-0",
- }),
- }),
- ]);
-
- const view = render(
- ,
- );
-
- for (const label of ["1888", "1889"]) {
- expect(pillForLabel(view, label)).toHaveClass(
- "dark:border-glides-gray-400",
- );
- const pill = pillForLabel(view, label);
- expect(
- within(pill).getByRole("img", { name: "Non-revenue" }),
- ).toBeInTheDocument();
- }
- });
-
- test("renders revenue trains with pill color based on route pattern", () => {
- mockUseVehicles.mockReturnValue([
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1888",
- cars: ["1888", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: "11111",
- }),
- tripUpdate: tripUpdateFactory.build({
- routePatternId: "Red-1-0",
- }),
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1889",
- cars: ["1889", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: "22222",
- }),
- tripUpdate: tripUpdateFactory.build({
- routePatternId: "Red-3-0",
- }),
- }),
- ]);
-
- const view = render(
- ,
- );
-
- expect(pillForLabel(view, "1888")).toHaveClass(
- "branch-color-heavy-rail-ashmont",
- );
- expect(pillForLabel(view, "1889")).toHaveClass(
- "branch-color-heavy-rail-braintree",
- );
- });
-
- test("renders default pill color when route pattern is not provided", () => {
- mockUseVehicles.mockReturnValue([
- // Ashmont portion of ladder
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1888",
- cars: ["1888", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-jfk",
- stopId: "70085",
- tripId: null,
- }),
- tripUpdate: undefined,
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1889",
- cars: ["1889", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-jfk",
- stopId: "70086",
- tripId: null,
- }),
- tripUpdate: undefined,
- }),
- // Braintree portion of ladder
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1890",
- cars: ["1890", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-jfk",
- stopId: "70095",
- tripId: null,
- }),
- tripUpdate: undefined,
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1891",
- cars: ["1891", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-jfk",
- stopId: "70096",
- tripId: null,
- }),
- tripUpdate: undefined,
- }),
- // Alewife trunk portion of ladder
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1892",
- cars: ["1892", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70063",
- tripId: null,
- }),
- tripUpdate: undefined,
- }),
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1893",
- cars: ["1893", "1876", "1807", "1806", "1815", "1814"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: null,
- }),
- tripUpdate: undefined,
- }),
- ]);
-
- const view = render(
- ,
- );
-
- for (const label of ["1888", "1889", "1890", "1891", "1892", "1893"]) {
- expect(pillForLabel(view, label)).toHaveClass(
- "branch-color-heavy-rail-braintree",
- );
- }
- });
- });
-
- describe("pill highlight", () => {
- test("when the sidebar is open from a search, the searched train is highlighted", () => {
- mockUseVehicles.mockReturnValue([
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1888",
- cars: ["1888", "1889", "1890", "1891"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: "11111",
- }),
- }),
- ]);
-
- const view = render(
- ,
- );
-
- expect(pillForLabel(view, "1888")).toHaveClass("ring-8");
- });
-
- test("when the sidebar is not open, trains are not highlighted", () => {
- mockUseVehicles.mockReturnValue([
- vehicleFactory.build({
- vehiclePosition: vehiclePositionFactory.build({
- vehicleId: nextVehicleId(),
- label: "1888",
- cars: ["1888", "1889", "1890", "1891"],
- stationId: "place-davis",
- stopId: "70064",
- tripId: "11111",
- }),
- }),
- ]);
-
- const view = render(
- ,
- );
-
- expect(pillForLabel(view, "1888")).not.toHaveClass("ring-8");
- });
- });
-
describe("branch selection on train click", () => {
beforeAll(() => {
mockGetMetaContent.mockImplementation((field: MetaDataKey) => {
From dd38cd1dd15050b227e0fb5d5260c2e75d4e6f50 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:52:03 -0400
Subject: [PATCH 17/28] chore: remove testing comment
---
js/test/components/ladderPageShared/ladderPage.test.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index 2c7e777d..a96c637c 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -351,8 +351,6 @@ describe("LadderPage BranchPicker visibility", () => {
test("BranchPicker is hidden by default (no overflow in jsdom)", () => {
const view = render();
- // Station names are buttons rendered by rail-tech-ui, so scope to the
- // BranchPicker container rather than matching by branch name alone
expect(view.queryByTestId("branch-picker")).not.toBeInTheDocument();
});
From 6fd76b5a2402ff667fc67f94f4b470699acdff0e Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Mon, 31 Aug 2026 13:22:50 -0400
Subject: [PATCH 18/28] chore: remove redundant name from props
---
.../ladderPageShared/branchPicker.tsx | 12 +++---
js/components/ladderPageShared/ladderPage.tsx | 4 +-
.../ladderPageShared/branchPicker.test.tsx | 42 ++++---------------
3 files changed, 17 insertions(+), 41 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index 9e038c80..adb1d0f0 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -56,11 +56,11 @@ const BranchButton = ({
};
export const BranchPicker = ({
- branchPickerSelection,
- setBranchPickerSelection,
+ selection,
+ setSelection,
}: {
- branchPickerSelection: BranchPickerSelection;
- setBranchPickerSelection: (selection: BranchPickerSelection) => void;
+ selection: BranchPickerSelection;
+ setSelection: (selection: BranchPickerSelection) => void;
}) => {
return (
{
- setBranchPickerSelection(branch);
+ setSelection(branch);
}}
/>
))}
diff --git a/js/components/ladderPageShared/ladderPage.tsx b/js/components/ladderPageShared/ladderPage.tsx
index c377594c..6dc7c1c2 100644
--- a/js/components/ladderPageShared/ladderPage.tsx
+++ b/js/components/ladderPageShared/ladderPage.tsx
@@ -140,8 +140,8 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
{isOverflowing && (
)}
diff --git a/js/test/components/ladderPageShared/branchPicker.test.tsx b/js/test/components/ladderPageShared/branchPicker.test.tsx
index 59d03abd..087a1ca7 100644
--- a/js/test/components/ladderPageShared/branchPicker.test.tsx
+++ b/js/test/components/ladderPageShared/branchPicker.test.tsx
@@ -29,10 +29,7 @@ const inactiveDotText: Record
= {
describe("BranchPicker", () => {
test("renders all three branch buttons", () => {
const view = render(
- ,
+ ,
);
expect(view.getByRole("button", { name: /Alewife/i })).toBeInTheDocument();
expect(view.getByRole("button", { name: /Ashmont/i })).toBeInTheDocument();
@@ -44,10 +41,7 @@ describe("BranchPicker", () => {
describe.each(branches)("when %s is selected", (activeBranch) => {
test("active button has branch-specific background", () => {
const view = render(
- ,
+ ,
);
expect(
view.getByRole("button", { name: new RegExp(activeBranch, "i") }),
@@ -56,10 +50,7 @@ describe("BranchPicker", () => {
test("inactive buttons have default background", () => {
const view = render(
- ,
+ ,
);
branches
.filter((b) => b !== activeBranch)
@@ -72,10 +63,7 @@ describe("BranchPicker", () => {
test("active button label has dark-blue text", () => {
const view = render(
- ,
+ ,
);
const button = view.getByRole("button", {
name: new RegExp(activeBranch, "i"),
@@ -87,10 +75,7 @@ describe("BranchPicker", () => {
test("inactive button labels have white text", () => {
const view = render(
- ,
+ ,
);
branches
.filter((b) => b !== activeBranch)
@@ -106,10 +91,7 @@ describe("BranchPicker", () => {
test("inactive button dots have branch-specific color", () => {
const view = render(
- ,
+ ,
);
branches
.filter((b) => b !== activeBranch)
@@ -125,10 +107,7 @@ describe("BranchPicker", () => {
test("active button dot has dark-blue text", () => {
const view = render(
- ,
+ ,
);
const button = view.getByRole("button", {
name: new RegExp(activeBranch, "i"),
@@ -139,15 +118,12 @@ describe("BranchPicker", () => {
describe("clicking buttons", () => {
test.each(branches)(
- "clicking %s calls setBranchPickerSelection with correct value",
+ "clicking %s calls setSelection with correct value",
async (branch) => {
const mockSet = jest.fn();
const user = userEvent.setup();
const view = render(
- ,
+ ,
);
await user.click(
view.getByRole("button", { name: new RegExp(branch, "i") }),
From 1423ffcbe51011aab8f5c5be54cdd55e681fafbe Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Mon, 31 Aug 2026 14:07:22 -0400
Subject: [PATCH 19/28] refactor: infer selection type from branches array
---
js/components/ladderPageShared/branchPicker.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index adb1d0f0..09803788 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,4 +1,5 @@
-export type BranchPickerSelection = "Alewife" | "Ashmont" | "Braintree";
+const branches = ["Alewife", "Ashmont", "Braintree"] as const;
+export type BranchPickerSelection = (typeof branches)[number];
const defaultBg =
"bg-ladder-branch-picker-inactive-bg-dark light:bg-ladder-branch-picker-inactive-bg-light";
@@ -67,7 +68,7 @@ export const BranchPicker = ({
className="flex justify-between h-14 self-center gap-1 w-full max-w-[371px]"
data-testid="branch-picker"
>
- {(["Alewife", "Ashmont", "Braintree"] as const).map((branch) => (
+ {branches.map((branch) => (
Date: Tue, 1 Sep 2026 08:20:46 -0400
Subject: [PATCH 20/28] refactor: nest branches under route ID key
---
.../ladderPageShared/branchPicker.tsx | 36 ++++++++++---------
1 file changed, 20 insertions(+), 16 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index 09803788..ab2d2c64 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,3 +1,5 @@
+export type Route = "Red" | "Orange" | "Blue";
+
const branches = ["Alewife", "Ashmont", "Braintree"] as const;
export type BranchPickerSelection = (typeof branches)[number];
@@ -7,22 +9,24 @@ const defaultBg =
const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
-const branchColors: Record<
- BranchPickerSelection,
- { bg: string; dotText: string }
+const branchColors: Pick<
+ Record>,
+ "Red"
> = {
- Alewife: {
- bg: "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
- dotText:
- "text-ladder-branch-picker-alewife-dot-dark light:text-ladder-branch-picker-alewife-dot-light",
- },
- Ashmont: {
- bg: "bg-heavy-rail-ashmont",
- dotText: "text-heavy-rail-ashmont",
- },
- Braintree: {
- bg: "bg-heavy-rail-braintree",
- dotText: "text-heavy-rail-braintree",
+ Red: {
+ Alewife: {
+ bg: "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
+ dotText:
+ "text-ladder-branch-picker-alewife-dot-dark light:text-ladder-branch-picker-alewife-dot-light",
+ },
+ Ashmont: {
+ bg: "bg-heavy-rail-ashmont",
+ dotText: "text-heavy-rail-ashmont",
+ },
+ Braintree: {
+ bg: "bg-heavy-rail-braintree",
+ dotText: "text-heavy-rail-braintree",
+ },
},
};
@@ -35,7 +39,7 @@ const BranchButton = ({
isActive: boolean;
onClick: () => void;
}) => {
- const { bg, dotText } = branchColors[branch];
+ const { bg, dotText } = branchColors.Red[branch];
const buttonBg = isActive ? bg : defaultBg;
const labelText = isActive ? activeText : "text-white";
const dotColor = isActive ? activeText : dotText;
From d7efdc8408e164ba051806e8c622c9c32360b748 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Tue, 1 Sep 2026 11:28:12 -0400
Subject: [PATCH 21/28] refactor: use existing RouteID type
---
js/components/ladderPageShared/branchPicker.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index ab2d2c64..d0bf4b52 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,4 +1,4 @@
-export type Route = "Red" | "Orange" | "Blue";
+import { RouteId } from "../../models/common";
const branches = ["Alewife", "Ashmont", "Braintree"] as const;
export type BranchPickerSelection = (typeof branches)[number];
@@ -10,7 +10,10 @@ const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
const branchColors: Pick<
- Record>,
+ Record<
+ RouteId,
+ Record
+ >,
"Red"
> = {
Red: {
From 9834228b763b7a0fac93fcf311d59d2a107e754f Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Tue, 1 Sep 2026 11:47:17 -0400
Subject: [PATCH 22/28] refactor: do not use pick in branch colors type
---
js/components/ladderPageShared/branchPicker.tsx | 15 +++++++--------
1 file changed, 7 insertions(+), 8 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index d0bf4b52..a73f9bd1 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -9,13 +9,7 @@ const defaultBg =
const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
-const branchColors: Pick<
- Record<
- RouteId,
- Record
- >,
- "Red"
-> = {
+const branchColors = {
Red: {
Alewife: {
bg: "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
@@ -31,7 +25,12 @@ const branchColors: Pick<
dotText: "text-heavy-rail-braintree",
},
},
-};
+} satisfies Partial<
+ Record<
+ RouteId,
+ Record
+ >
+>;
const BranchButton = ({
branch,
From 3b06ead93fdcef445a2c9008bad9b3febc02fb67 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:59:19 -0400
Subject: [PATCH 23/28] refactor: set branch for each ladder on render
(avoiding station lookup on each click)
---
js/components/ladderPageShared/ladder.tsx | 112 +++++++++++-----------
1 file changed, 57 insertions(+), 55 deletions(-)
diff --git a/js/components/ladderPageShared/ladder.tsx b/js/components/ladderPageShared/ladder.tsx
index 95cc7a42..0202661b 100644
--- a/js/components/ladderPageShared/ladder.tsx
+++ b/js/components/ladderPageShared/ladder.tsx
@@ -140,7 +140,10 @@ export const Ladders = ({
),
);
- const onVehicleSelection = (selection: VehicleSelection) => {
+ const onVehicleSelection = (
+ selection: VehicleSelection,
+ branch: BranchPickerSelection,
+ ) => {
const match = vehicles.find((vehicle) =>
consistsEqual(
vehicle.vehiclePosition.cars,
@@ -148,17 +151,7 @@ export const Ladders = ({
),
);
if (match) {
- // update branch picker to reflect the branch the clicked train is on
- const matchingStationList = stationLists.find((stations) =>
- stations.some((station) =>
- station.stop_ids.some(
- (stopId) => stopId === match.vehiclePosition.stopId,
- ),
- ),
- );
- if (matchingStationList) {
- setBranchPickerSelection(branchForLadder(matchingStationList));
- }
+ setBranchPickerSelection(branch);
const sameVehicle =
sideBarSelection !== null &&
@@ -187,51 +180,60 @@ export const Ladders = ({
className="relative flex w-full h-full justify-start min-[1485px]:justify-center overflow-x-auto snap-x snap-mandatory"
>
{Array.from(vehiclesByBranch.entries()).map(
- ([stationList, branchVehicles], index) => (
-
-
undefined}
- eastToWestStations={stationList.map(toLadderStation)}
- letterFn={(routeId: RouteId, routePatternId?: RoutePatternId) => {
- if (routePatternId !== undefined) {
- return ROUTE_PATTERN_CONFIG[routeId][routePatternId].letter;
- }
+ ([stationList, branchVehicles], index) => {
+ const branch = branchForLadder(stationList);
- return ROUTE_DEFAULTS[routeId].letter;
- }}
- routeColorFn={(
- routeId: RouteId,
- routePatternId?: RoutePatternId,
- ) => {
- if (routePatternId !== undefined) {
- return ROUTE_PATTERN_CONFIG[routeId][routePatternId].color;
- }
+ return (
+
+ {
+ onVehicleSelection(selection, branch);
+ }}
+ setStationSelection={() => undefined}
+ eastToWestStations={stationList.map(toLadderStation)}
+ letterFn={(
+ routeId: RouteId,
+ routePatternId?: RoutePatternId,
+ ) => {
+ if (routePatternId !== undefined) {
+ return ROUTE_PATTERN_CONFIG[routeId][routePatternId].letter;
+ }
- return ROUTE_DEFAULTS[routeId].color;
- }}
- labelRemap={(car: CarId) => remapLabel(car, routeId)}
- getInitialPredictionsDirection={() => 0}
- />
-
- ),
+ return ROUTE_DEFAULTS[routeId].letter;
+ }}
+ routeColorFn={(
+ routeId: RouteId,
+ routePatternId?: RoutePatternId,
+ ) => {
+ if (routePatternId !== undefined) {
+ return ROUTE_PATTERN_CONFIG[routeId][routePatternId].color;
+ }
+
+ return ROUTE_DEFAULTS[routeId].color;
+ }}
+ labelRemap={(car: CarId) => remapLabel(car, routeId)}
+ getInitialPredictionsDirection={() => 0}
+ />
+
+ );
+ },
)}
);
From 1507e6a4a93469812723cba92a97d1b66e3fe139 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Wed, 9 Sep 2026 10:41:32 -0400
Subject: [PATCH 24/28] refactor: nest branches under route key
This makes the data type route-agnostic.
It changes the `branchColors` type definition to match the
ROUTE_DEFAULTS from ladder, as the partial was causing issues.
Update branch button map to temporarily hard-code "Red" as route
---
.../ladderPageShared/branchPicker.tsx | 26 ++++++++++++-------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index a73f9bd1..fd2c24ca 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,7 +1,13 @@
import { RouteId } from "../../models/common";
+import { Route } from "@playwright/test";
-const branches = ["Alewife", "Ashmont", "Braintree"] as const;
-export type BranchPickerSelection = (typeof branches)[number];
+// make route-agnostic
+// const branches = ["Alewife", "Ashmont", "Braintree"] as const;
+const routeBranches = {
+ Red: ["Alewife", "Ashmont", "Braintree"] as const,
+};
+// export type BranchPickerSelection = (typeof branches)[number];
+export type BranchPickerSelection = string;
const defaultBg =
"bg-ladder-branch-picker-inactive-bg-dark light:bg-ladder-branch-picker-inactive-bg-light";
@@ -9,7 +15,12 @@ const defaultBg =
const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
-const branchColors = {
+const branchColors: Readonly<
+ Record<
+ RouteId,
+ Record
+ >
+> = {
Red: {
Alewife: {
bg: "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
@@ -25,12 +36,7 @@ const branchColors = {
dotText: "text-heavy-rail-braintree",
},
},
-} satisfies Partial<
- Record<
- RouteId,
- Record
- >
->;
+};
const BranchButton = ({
branch,
@@ -74,7 +80,7 @@ export const BranchPicker = ({
className="flex justify-between h-14 self-center gap-1 w-full max-w-[371px]"
data-testid="branch-picker"
>
- {branches.map((branch) => (
+ {routeBranches.Red.map((branch) => (
Date: Wed, 9 Sep 2026 12:02:52 -0400
Subject: [PATCH 25/28] refactor: make branch selections route-agnostic
---
.../ladderPageShared/branchPicker.tsx | 17 ++++---
js/components/ladderPageShared/ladder.tsx | 14 +++---
js/components/ladderPageShared/ladderPage.tsx | 1 +
.../ladderPageShared/branchPicker.test.tsx | 44 +++++++++++++++----
4 files changed, 56 insertions(+), 20 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index fd2c24ca..5adaf29d 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,12 +1,10 @@
import { RouteId } from "../../models/common";
-import { Route } from "@playwright/test";
-// make route-agnostic
-// const branches = ["Alewife", "Ashmont", "Braintree"] as const;
-const routeBranches = {
+const routeBranches: Readonly<
+ Record
+> = {
Red: ["Alewife", "Ashmont", "Braintree"] as const,
};
-// export type BranchPickerSelection = (typeof branches)[number];
export type BranchPickerSelection = string;
const defaultBg =
@@ -39,15 +37,17 @@ const branchColors: Readonly<
};
const BranchButton = ({
+ route,
branch,
isActive,
onClick,
}: {
+ route: RouteId;
branch: BranchPickerSelection;
isActive: boolean;
onClick: () => void;
}) => {
- const { bg, dotText } = branchColors.Red[branch];
+ const { bg, dotText } = branchColors[route][branch];
const buttonBg = isActive ? bg : defaultBg;
const labelText = isActive ? activeText : "text-white";
const dotColor = isActive ? activeText : dotText;
@@ -69,9 +69,11 @@ const BranchButton = ({
};
export const BranchPicker = ({
+ route,
selection,
setSelection,
}: {
+ route: RouteId;
selection: BranchPickerSelection;
setSelection: (selection: BranchPickerSelection) => void;
}) => {
@@ -80,9 +82,10 @@ export const BranchPicker = ({
className="flex justify-between h-14 self-center gap-1 w-full max-w-[371px]"
data-testid="branch-picker"
>
- {routeBranches.Red.map((branch) => (
+ {routeBranches[route].map((branch) => (
{
diff --git a/js/components/ladderPageShared/ladder.tsx b/js/components/ladderPageShared/ladder.tsx
index 1f2b38fb..d183d642 100644
--- a/js/components/ladderPageShared/ladder.tsx
+++ b/js/components/ladderPageShared/ladder.tsx
@@ -65,10 +65,14 @@ export type VehicleWithHeight = {
heights: TrainHeight;
};
-const branchForLadder = (ladderConfig: LadderConfig): BranchPickerSelection => {
- if (ladderConfig.some((s) => s.id === "place-asmnl")) return "Ashmont";
- if (ladderConfig.some((s) => s.id === "place-brntn")) return "Braintree";
- return "Alewife";
+const branchForLadder = (
+ routeId: RouteId,
+ ladderConfig: LadderConfig,
+): BranchPickerSelection => {
+ return ladderConfig[
+ // First ladder is Alewife, for other branches use name of last station
+ Stations[routeId].indexOf(ladderConfig) === 0 ? 0 : ladderConfig.length - 1
+ ].name;
};
// Adapt Orbit's Station (uses `location`, no `shortName`) to rail-tech-ui's
@@ -181,7 +185,7 @@ export const Ladders = ({
>
{Array.from(vehiclesByBranch.entries()).map(
([stationList, branchVehicles], index) => {
- const branch = branchForLadder(stationList);
+ const branch = branchForLadder(routeId, stationList);
return (
{
{isOverflowing && (
diff --git a/js/test/components/ladderPageShared/branchPicker.test.tsx b/js/test/components/ladderPageShared/branchPicker.test.tsx
index 087a1ca7..33ed754f 100644
--- a/js/test/components/ladderPageShared/branchPicker.test.tsx
+++ b/js/test/components/ladderPageShared/branchPicker.test.tsx
@@ -29,7 +29,7 @@ const inactiveDotText: Record
= {
describe("BranchPicker", () => {
test("renders all three branch buttons", () => {
const view = render(
- ,
+ ,
);
expect(view.getByRole("button", { name: /Alewife/i })).toBeInTheDocument();
expect(view.getByRole("button", { name: /Ashmont/i })).toBeInTheDocument();
@@ -41,7 +41,11 @@ describe("BranchPicker", () => {
describe.each(branches)("when %s is selected", (activeBranch) => {
test("active button has branch-specific background", () => {
const view = render(
- ,
+ ,
);
expect(
view.getByRole("button", { name: new RegExp(activeBranch, "i") }),
@@ -50,7 +54,11 @@ describe("BranchPicker", () => {
test("inactive buttons have default background", () => {
const view = render(
- ,
+ ,
);
branches
.filter((b) => b !== activeBranch)
@@ -63,7 +71,11 @@ describe("BranchPicker", () => {
test("active button label has dark-blue text", () => {
const view = render(
- ,
+ ,
);
const button = view.getByRole("button", {
name: new RegExp(activeBranch, "i"),
@@ -75,7 +87,11 @@ describe("BranchPicker", () => {
test("inactive button labels have white text", () => {
const view = render(
- ,
+ ,
);
branches
.filter((b) => b !== activeBranch)
@@ -91,7 +107,11 @@ describe("BranchPicker", () => {
test("inactive button dots have branch-specific color", () => {
const view = render(
- ,
+ ,
);
branches
.filter((b) => b !== activeBranch)
@@ -107,7 +127,11 @@ describe("BranchPicker", () => {
test("active button dot has dark-blue text", () => {
const view = render(
- ,
+ ,
);
const button = view.getByRole("button", {
name: new RegExp(activeBranch, "i"),
@@ -123,7 +147,11 @@ describe("BranchPicker", () => {
const mockSet = jest.fn();
const user = userEvent.setup();
const view = render(
- ,
+ ,
);
await user.click(
view.getByRole("button", { name: new RegExp(branch, "i") }),
From b117bcc6b0a3c438f20ce56eea10f691dae0a207 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:20:56 -0400
Subject: [PATCH 26/28] refactor: update tests to include route
---
.../ladderPageShared/branchPicker.test.tsx | 69 ++++++++++---------
1 file changed, 35 insertions(+), 34 deletions(-)
diff --git a/js/test/components/ladderPageShared/branchPicker.test.tsx b/js/test/components/ladderPageShared/branchPicker.test.tsx
index 33ed754f..e9816f1d 100644
--- a/js/test/components/ladderPageShared/branchPicker.test.tsx
+++ b/js/test/components/ladderPageShared/branchPicker.test.tsx
@@ -2,6 +2,7 @@ import {
BranchPicker,
BranchPickerSelection,
} from "../../../components/ladderPageShared/branchPicker";
+import { RouteId } from "../../../models/common";
import { render, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -10,13 +11,19 @@ const defaultBg =
const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
-const branches: BranchPickerSelection[] = ["Alewife", "Ashmont", "Braintree"];
+const branches: Readonly> = {
+ Red: ["Alewife", "Ashmont", "Braintree"],
+};
-const activeBg: Record = {
- Alewife:
- "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
- Ashmont: "bg-heavy-rail-ashmont",
- Braintree: "bg-heavy-rail-braintree",
+const activeBg: Readonly<
+ Record>
+> = {
+ Red: {
+ Alewife:
+ "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
+ Ashmont: "bg-heavy-rail-ashmont",
+ Braintree: "bg-heavy-rail-braintree",
+ },
};
const inactiveDotText: Record = {
@@ -38,7 +45,7 @@ describe("BranchPicker", () => {
).toBeInTheDocument();
});
- describe.each(branches)("when %s is selected", (activeBranch) => {
+ describe.each(branches.Red)("when %s is selected", (activeBranch) => {
test("active button has branch-specific background", () => {
const view = render(
{
);
expect(
view.getByRole("button", { name: new RegExp(activeBranch, "i") }),
- ).toHaveClass(activeBg[activeBranch]);
+ ).toHaveClass(activeBg.Red[activeBranch]);
});
test("inactive buttons have default background", () => {
@@ -60,13 +67,11 @@ describe("BranchPicker", () => {
setSelection={jest.fn()}
/>,
);
- branches
- .filter((b) => b !== activeBranch)
- .forEach((branch) => {
- expect(
- view.getByRole("button", { name: new RegExp(branch, "i") }),
- ).toHaveClass(defaultBg);
- });
+ branches.Red.filter((b) => b !== activeBranch).forEach((branch) => {
+ expect(
+ view.getByRole("button", { name: new RegExp(branch, "i") }),
+ ).toHaveClass(defaultBg);
+ });
});
test("active button label has dark-blue text", () => {
@@ -93,16 +98,14 @@ describe("BranchPicker", () => {
setSelection={jest.fn()}
/>,
);
- branches
- .filter((b) => b !== activeBranch)
- .forEach((branch) => {
- const button = view.getByRole("button", {
- name: new RegExp(branch, "i"),
- });
- expect(within(button).getByTestId("branch-label")).toHaveClass(
- "text-white",
- );
+ branches.Red.filter((b) => b !== activeBranch).forEach((branch) => {
+ const button = view.getByRole("button", {
+ name: new RegExp(branch, "i"),
});
+ expect(within(button).getByTestId("branch-label")).toHaveClass(
+ "text-white",
+ );
+ });
});
test("inactive button dots have branch-specific color", () => {
@@ -113,16 +116,14 @@ describe("BranchPicker", () => {
setSelection={jest.fn()}
/>,
);
- branches
- .filter((b) => b !== activeBranch)
- .forEach((branch) => {
- const button = view.getByRole("button", {
- name: new RegExp(branch, "i"),
- });
- expect(within(button).getByTestId("branch-dot")).toHaveClass(
- inactiveDotText[branch],
- );
+ branches.Red.filter((b) => b !== activeBranch).forEach((branch) => {
+ const button = view.getByRole("button", {
+ name: new RegExp(branch, "i"),
});
+ expect(within(button).getByTestId("branch-dot")).toHaveClass(
+ inactiveDotText[branch],
+ );
+ });
});
test("active button dot has dark-blue text", () => {
@@ -141,7 +142,7 @@ describe("BranchPicker", () => {
});
describe("clicking buttons", () => {
- test.each(branches)(
+ test.each(branches.Red)(
"clicking %s calls setSelection with correct value",
async (branch) => {
const mockSet = jest.fn();
From 7be6942d9ac8f7db5bf2deff3e769153d9f81180 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:59:21 -0400
Subject: [PATCH 27/28] refactor: debounce window resize check
---
js/components/ladderPageShared/ladderPage.tsx | 28 +++++++++++++++++--
.../ladderPageShared/ladderPage.test.tsx | 14 ++++++++++
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/js/components/ladderPageShared/ladderPage.tsx b/js/components/ladderPageShared/ladderPage.tsx
index 098d06bc..52714cd5 100644
--- a/js/components/ladderPageShared/ladderPage.tsx
+++ b/js/components/ladderPageShared/ladderPage.tsx
@@ -22,6 +22,9 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
useState("Ashmont");
const [searchQuery, setSearchQuery] = useState("");
const [isOverflowing, setIsOverflowing] = useState(false);
+ const [resizeTimeout, setResizeTimeout] = useState | null>(null);
const laddersRef = useRef(null);
const openSideBar = useCallback(
@@ -63,12 +66,31 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
const check = () => {
setIsOverflowing(el.scrollWidth > el.clientWidth);
};
+
+ const onResize = () => {
+ setResizeTimeout((currentTimeout) => {
+ if (currentTimeout !== null) {
+ clearTimeout(currentTimeout);
+ }
+ return setTimeout(check, 100);
+ });
+ };
+
check();
- window.addEventListener("resize", check);
+ window.addEventListener("resize", onResize);
+
return () => {
- window.removeEventListener("resize", check);
+ window.removeEventListener("resize", onResize);
+ };
+ }, [setResizeTimeout]);
+
+ useEffect(() => {
+ return () => {
+ if (resizeTimeout !== null) {
+ clearTimeout(resizeTimeout);
+ }
};
- }, []);
+ }, [resizeTimeout]);
const onSearchMatch = useCallback(
(match: VehicleSearchMatch): boolean => {
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index 6b5de2bd..5d86cb8e 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -354,10 +354,15 @@ describe("LadderPage SideBar", () => {
describe("LadderPage BranchPicker visibility", () => {
beforeEach(() => {
+ jest.useFakeTimers();
mockUseVehicles.mockReturnValue([vehicleFactory.build()]);
mockGetMetaContent.mockReturnValue(null);
});
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
test("BranchPicker is hidden by default (no overflow in jsdom)", () => {
const view = render();
expect(view.queryByTestId("branch-picker")).not.toBeInTheDocument();
@@ -377,6 +382,9 @@ describe("LadderPage BranchPicker visibility", () => {
act(() => {
window.dispatchEvent(new Event("resize"));
});
+ act(() => {
+ jest.advanceTimersByTime(150);
+ });
const branchPicker = getByTestId("branch-picker");
expect(
@@ -404,6 +412,9 @@ describe("LadderPage BranchPicker visibility", () => {
act(() => {
window.dispatchEvent(new Event("resize"));
});
+ act(() => {
+ jest.advanceTimersByTime(150);
+ });
// then resolve overflow
// eslint-disable-next-line better-mutation/no-mutating-functions
@@ -414,6 +425,9 @@ describe("LadderPage BranchPicker visibility", () => {
act(() => {
window.dispatchEvent(new Event("resize"));
});
+ act(() => {
+ jest.advanceTimersByTime(150);
+ });
expect(queryByTestId("branch-picker")).not.toBeInTheDocument();
});
From 0b91e8538214f82833556d42ce8b8a01f20c5228 Mon Sep 17 00:00:00 2001
From: Dave Powers <4978418+djpowers@users.noreply.github.com>
Date: Fri, 11 Sep 2026 17:18:39 -0400
Subject: [PATCH 28/28] refactor: use index-based type for storing branch
Storing the branch as a string required performing a reverse-lookup to
determine which branch a particular station was part of (since a branch)
name is not part of the underlying data structure.
This instead uses the index of the defined branch, which we can rely on
to be in a consistent order. This updates related tests to follow this
new structure.
---
.../ladderPageShared/branchPicker.tsx | 21 +++++-----
js/components/ladderPageShared/ladder.tsx | 12 +-----
js/components/ladderPageShared/ladderPage.tsx | 2 +-
.../ladderPageShared/branchPicker.test.tsx | 38 ++++++++++++-------
.../ladderPageShared/ladderPage.test.tsx | 6 +--
5 files changed, 39 insertions(+), 40 deletions(-)
diff --git a/js/components/ladderPageShared/branchPicker.tsx b/js/components/ladderPageShared/branchPicker.tsx
index 5adaf29d..fa84a957 100644
--- a/js/components/ladderPageShared/branchPicker.tsx
+++ b/js/components/ladderPageShared/branchPicker.tsx
@@ -1,11 +1,12 @@
import { RouteId } from "../../models/common";
-const routeBranches: Readonly<
- Record
-> = {
+// Use index from LadderConfig sub-array (Stations[routeId])
+// i.e. 0 for Alewife, 1 for Ashmont, 2 for Braintree
+export type BranchPickerSelection = number;
+
+const routeBranchLabels: Readonly> = {
Red: ["Alewife", "Ashmont", "Braintree"] as const,
};
-export type BranchPickerSelection = string;
const defaultBg =
"bg-ladder-branch-picker-inactive-bg-dark light:bg-ladder-branch-picker-inactive-bg-light";
@@ -14,10 +15,7 @@ const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";
const branchColors: Readonly<
- Record<
- RouteId,
- Record
- >
+ Record>
> = {
Red: {
Alewife: {
@@ -47,7 +45,8 @@ const BranchButton = ({
isActive: boolean;
onClick: () => void;
}) => {
- const { bg, dotText } = branchColors[route][branch];
+ const label = routeBranchLabels[route][branch];
+ const { bg, dotText } = branchColors[route][label];
const buttonBg = isActive ? bg : defaultBg;
const labelText = isActive ? activeText : "text-white";
const dotColor = isActive ? activeText : dotText;
@@ -58,7 +57,7 @@ const BranchButton = ({
onClick={onClick}
>
- {branch}
+ {label}
- {routeBranches[route].map((branch) => (
+ {routeBranchLabels[route].map((_label, branch) => (
{
- return ladderConfig[
- // First ladder is Alewife, for other branches use name of last station
- Stations[routeId].indexOf(ladderConfig) === 0 ? 0 : ladderConfig.length - 1
- ].name;
-};
-
// Adapt Orbit's Station (uses `location`, no `shortName`) to rail-tech-ui's
// LadderStation shape (`latLng`, requires `shortName`).
// TODO: After we remove the old ladder, this can be simplified
@@ -185,7 +175,7 @@ export const Ladders = ({
>
{Array.from(vehiclesByBranch.entries()).map(
([stationList, branchVehicles], index) => {
- const branch = branchForLadder(routeId, stationList);
+ const branch: BranchPickerSelection = index;
return (
{
const [sideBarSelection, setSideBarSelection] =
useState(null);
const [branchPickerSelection, setBranchPickerSelection] =
- useState("Ashmont");
+ useState(1);
const [searchQuery, setSearchQuery] = useState("");
const [isOverflowing, setIsOverflowing] = useState(false);
const [resizeTimeout, setResizeTimeout] = useState> = {
+const branches: Readonly> = {
Red: ["Alewife", "Ashmont", "Braintree"],
};
-const activeBg: Readonly<
- Record>
-> = {
+// Maps branch label to its BranchPickerSelection index
+// (0 = Alewife, 1 = Ashmont, 2 = Braintree)
+const branchIndex: Record = {
+ Alewife: 0,
+ Ashmont: 1,
+ Braintree: 2,
+};
+
+const activeBg: Readonly>> = {
Red: {
Alewife:
"bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
@@ -26,7 +32,7 @@ const activeBg: Readonly<
},
};
-const inactiveDotText: Record = {
+const inactiveDotText: Record = {
Alewife:
"text-ladder-branch-picker-alewife-dot-dark light:text-ladder-branch-picker-alewife-dot-light",
Ashmont: "text-heavy-rail-ashmont",
@@ -36,7 +42,11 @@ const inactiveDotText: Record = {
describe("BranchPicker", () => {
test("renders all three branch buttons", () => {
const view = render(
- ,
+ ,
);
expect(view.getByRole("button", { name: /Alewife/i })).toBeInTheDocument();
expect(view.getByRole("button", { name: /Ashmont/i })).toBeInTheDocument();
@@ -50,7 +60,7 @@ describe("BranchPicker", () => {
const view = render(
,
);
@@ -63,7 +73,7 @@ describe("BranchPicker", () => {
const view = render(
,
);
@@ -78,7 +88,7 @@ describe("BranchPicker", () => {
const view = render(
,
);
@@ -94,7 +104,7 @@ describe("BranchPicker", () => {
const view = render(
,
);
@@ -112,7 +122,7 @@ describe("BranchPicker", () => {
const view = render(
,
);
@@ -130,7 +140,7 @@ describe("BranchPicker", () => {
const view = render(
,
);
@@ -150,14 +160,14 @@ describe("BranchPicker", () => {
const view = render(
,
);
await user.click(
view.getByRole("button", { name: new RegExp(branch, "i") }),
);
- expect(mockSet).toHaveBeenCalledWith(branch);
+ expect(mockSet).toHaveBeenCalledWith(branchIndex[branch]);
},
);
});
diff --git a/js/test/components/ladderPageShared/ladderPage.test.tsx b/js/test/components/ladderPageShared/ladderPage.test.tsx
index 5d86cb8e..dc403938 100644
--- a/js/test/components/ladderPageShared/ladderPage.test.tsx
+++ b/js/test/components/ladderPageShared/ladderPage.test.tsx
@@ -493,7 +493,7 @@ describe("Ladder", () => {
);
await user.click(view.getByRole("button", { name: /1999/ }));
- expect(mockSetBranch).toHaveBeenCalledWith("Ashmont");
+ expect(mockSetBranch).toHaveBeenCalledWith(1);
});
test("clicking a train on the Braintree ladder calls setBranchPickerSelection with Braintree", async () => {
@@ -524,7 +524,7 @@ describe("Ladder", () => {
);
await user.click(view.getByRole("button", { name: /2001/ }));
- expect(mockSetBranch).toHaveBeenCalledWith("Braintree");
+ expect(mockSetBranch).toHaveBeenCalledWith(2);
});
test("clicking a train on the Alewife trunk ladder calls setBranchPickerSelection with Alewife", async () => {
@@ -555,7 +555,7 @@ describe("Ladder", () => {
);
await user.click(view.getByRole("button", { name: /1888/ }));
- expect(mockSetBranch).toHaveBeenCalledWith("Alewife");
+ expect(mockSetBranch).toHaveBeenCalledWith(0);
});
});
});