From f3b3fab59aca06ffa223b3a6dcf67e65ba1f9cec Mon Sep 17 00:00:00 2001
From: fretchen
Date: Thu, 15 Oct 2026 07:43:44 +0200
Subject: [PATCH 1/5] new tool for train search
---
website/components/AssistantChat.tsx | 136 +-
website/locales/de.ts | 16 +
website/locales/en.ts | 16 +
website/prompts/tool-contract.md | 3 +-
website/test/AssistantChat.test.tsx | 153 +-
website/test/fixtures/transitous/geocode.json | 554 ++++++
website/test/fixtures/transitous/plan.json | 1757 +++++++++++++++++
.../test/fixtures/transitous/stoptimes.json | 830 ++++++++
website/test/prompts.test.ts | 1 +
website/test/transitous.test.ts | 207 ++
website/tools/transitous.ts | 310 +++
11 files changed, 3980 insertions(+), 3 deletions(-)
create mode 100644 website/test/fixtures/transitous/geocode.json
create mode 100644 website/test/fixtures/transitous/plan.json
create mode 100644 website/test/fixtures/transitous/stoptimes.json
create mode 100644 website/test/transitous.test.ts
create mode 100644 website/tools/transitous.ts
diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx
index 66efb4398..be7f99662 100644
--- a/website/components/AssistantChat.tsx
+++ b/website/components/AssistantChat.tsx
@@ -36,6 +36,18 @@ import {
fetchFailed,
type BundestaktResult,
} from "../tools/bundestakt";
+import {
+ searchStopTool,
+ planJourneyTool,
+ getDeparturesTool,
+ fetchGeocode,
+ fetchStoptimes,
+ fetchPlan,
+ selectStops,
+ selectBoard,
+ selectJourneys,
+ type TransitousResult,
+} from "../tools/transitous";
import {
getAnalyticsTool,
fetchStats,
@@ -95,7 +107,7 @@ import { PaymentError, readChargedTotal } from "../utils/x402PaidFetch";
* requirement; analytics is about honesty — a figure about this site should say it was looked up
* rather than read as something the model knew.
*/
-type ToolSource = "bundestakt" | "analytics" | "brave";
+type ToolSource = "bundestakt" | "transitous" | "analytics" | "brave";
/**
* Everything offered to the model, with what the chat loop needs to know about a tool besides its
@@ -161,6 +173,41 @@ export const TOOL_REGISTRY = [
paid: false,
source: "bundestakt",
},
+ // Transitous, the same UX decision as the Bundestakt pair above: three tools and schemas
+ // the model calls separately, one shared row for the visitor (labelled by the group key's
+ // locale value, "Mit Zug & Bus"/"Train & bus" — modes, not the project name, which says
+ // nothing to a visitor), collapsed by `group`. The row's description comes from the first
+ // member (see `toolSelectorOptions`), so `searchStopTool`'s pair below stands for all three.
+ {
+ tool: searchStopTool,
+ label: "assistent.toolTransitSearch",
+ description: "assistent.toolTransitSearchDesc",
+ group: "assistent.capTransitous",
+ ownerScope: null,
+ defaultAgentOnly: false,
+ paid: false,
+ source: "transitous",
+ },
+ {
+ tool: planJourneyTool,
+ label: "assistent.toolTransitPlan",
+ description: "assistent.toolTransitPlanDesc",
+ group: "assistent.capTransitous",
+ ownerScope: null,
+ defaultAgentOnly: false,
+ paid: false,
+ source: "transitous",
+ },
+ {
+ tool: getDeparturesTool,
+ label: "assistent.toolTransitDepartures",
+ description: "assistent.toolTransitDeparturesDesc",
+ group: "assistent.capTransitous",
+ ownerScope: null,
+ defaultAgentOnly: false,
+ paid: false,
+ source: "transitous",
+ },
{
tool: getPageTool,
label: "assistent.toolSiteContent",
@@ -466,9 +513,12 @@ export function AssistantChat() {
const bundestaktSourceLabel = useLocale({ label: "assistent.bundestaktSource" });
const analyticsSourceLabel = useLocale({ label: "assistent.analyticsSource" });
const braveSourceLabel = useLocale({ label: "assistent.braveSource" });
+ const transitousSourceLabel = useLocale({ label: "assistent.transitousSource" });
// Here rather than at module scope because the labels are translated per render.
const sourceLinks: Record = {
bundestakt: { href: "https://www.bundestakt.de", label: bundestaktSourceLabel },
+ // Transitous's usage policy asks for visible attribution — this citation line is it.
+ transitous: { href: "https://transitous.org", label: transitousSourceLabel },
analytics: { href: "/analytics", label: analyticsSourceLabel },
brave: { href: "https://search.brave.com", label: braveSourceLabel },
};
@@ -812,6 +862,87 @@ export function AssistantChat() {
}
}
+ /**
+ * Fetches and projects one Transitous endpoint (stop search, departure board, routing).
+ * `tools/transitous.ts` is stateless, so the caching policy lives here: geocode answers
+ * change rarely (24 h), a board goes stale within a minute (60 s), routing answers are
+ * keyed by the exact query (5 min) — politeness, not latency, since Transitous answers in
+ * well under a second. Unlike Bundestakt's `not_found`, a stop search without hits is
+ * still `status: "ok"` (empty list), so the tool stays on offer for a corrected spelling.
+ */
+ /**
+ * A tool argument the model sent as a non-string (or omitted) coerces to the empty string
+ * rather than to "[object Object]" — the empty-argument guards below turn that into a
+ * correctable bad_query answer instead of a request for a nonsense name.
+ */
+ const argString = (v: unknown): string => (typeof v === "string" ? v : "");
+
+ async function loadTransitous(kind: "geocode" | "stoptimes" | "plan", args: Record): Promise {
+ // An empty required argument is an answer the model can act on, not a malfunction: fetching
+ // anyway would 4xx at the API, `fetch_failed` would withdraw the tool for the rest of the
+ // turn, and with it the one thing that fixes the mistake — a corrected query, a real stop
+ // id from search_stop. `recoverable` keeps the tool on offer for the retry.
+ if (kind === "geocode" && !argString(args.query)) {
+ return {
+ result: { status: "bad_query", reason: "search_stop needs a non-empty stop or station name to search for." },
+ recoverable: true,
+ };
+ }
+ if (kind === "stoptimes" && !argString(args.stop_id)) {
+ return {
+ result: { status: "bad_query", reason: "get_departures needs a stop id from search_stop." },
+ recoverable: true,
+ };
+ }
+ if (kind === "plan" && (!argString(args.from) || !argString(args.to))) {
+ return {
+ result: { status: "bad_query", reason: "plan_journey needs from and to stop ids from search_stop." },
+ recoverable: true,
+ };
+ }
+ try {
+ let result: TransitousResult;
+ if (kind === "geocode") {
+ const query = argString(args.query);
+ const raw = await queryClient.fetchQuery({
+ queryKey: ["transitous", "geocode", query],
+ queryFn: () => fetchGeocode(query),
+ staleTime: 24 * 60 * 60_000,
+ retry: 0,
+ });
+ result = selectStops(raw);
+ } else if (kind === "stoptimes") {
+ const stopId = argString(args.stop_id);
+ const time = typeof args.time === "string" ? args.time : undefined;
+ const raw = await queryClient.fetchQuery({
+ queryKey: ["transitous", "stoptimes", stopId, time ?? null],
+ queryFn: () => fetchStoptimes(stopId, undefined, time),
+ staleTime: 60_000,
+ retry: 0,
+ });
+ result = selectBoard(raw);
+ } else {
+ const from = argString(args.from);
+ const to = argString(args.to);
+ const departure = typeof args.departure === "string" ? args.departure : undefined;
+ const arriveBy = args.arrive_by === true;
+ const railOnly = args.rail_only === true;
+ const raw = await queryClient.fetchQuery({
+ queryKey: ["transitous", "plan", from, to, departure ?? null, arriveBy, railOnly],
+ queryFn: () =>
+ fetchPlan({ from, to, time: departure, arriveBy, transitModes: railOnly ? "RAIL" : undefined }),
+ staleTime: 5 * 60_000,
+ retry: 0,
+ });
+ result = selectJourneys(raw);
+ }
+ return { result };
+ } catch (err) {
+ // fetchQuery rethrows the fetcher's error; the module owns the wire-level failure shape.
+ return { result: fetchFailed(err) };
+ }
+ }
+
/**
* Reads the site's own traffic figures. `GET /stats` takes no range and always returns the
* trailing year, so it is fetched once and every `range` is a local slice of that one payload.
@@ -987,6 +1118,9 @@ export function AssistantChat() {
toolRunners[generateImageTool.function.name] = runImageToolHere;
toolRunners[getSitzungenTool.function.name] = (args) => loadBundestakt("sitzungen", args);
toolRunners[searchClaimsTool.function.name] = (args) => loadBundestakt("claims", args);
+ toolRunners[searchStopTool.function.name] = (args) => loadTransitous("geocode", args);
+ toolRunners[planJourneyTool.function.name] = (args) => loadTransitous("plan", args);
+ toolRunners[getDeparturesTool.function.name] = (args) => loadTransitous("stoptimes", args);
toolRunners[getPageTool.function.name] = loadPage;
toolRunners[searchWebTool.function.name] = loadSearch;
toolRunners[fetchUrlTool.function.name] = loadFetch;
diff --git a/website/locales/de.ts b/website/locales/de.ts
index 4e46bba65..da1d61866 100644
--- a/website/locales/de.ts
+++ b/website/locales/de.ts
@@ -98,6 +98,9 @@ export default {
toolWebSearch: "Im Web suchen",
toolFetchUrl: "Links öffnen",
toolSiteAnalytics: "Website-Statistik",
+ toolTransitSearch: "Haltestellen suchen",
+ toolTransitPlan: "Verbindungen planen",
+ toolTransitDepartures: "Abfahrten",
// Eine Zeile pro Fähigkeit — der Anlass war ein Test, in dem jemand nicht wusste, was
// "Bundestagssitzungen" bedeutet. Kurz und konkret, keine Fachbegriffe ohne Erklärung.
toolImageGenerationDesc: "Erstellt ein Bild aus deiner Beschreibung.",
@@ -110,10 +113,22 @@ export default {
toolWebSearchDesc: "Sucht aktuelle Informationen im Internet.",
toolFetchUrlDesc: "Liest eine Seite, die du verlinkst.",
toolSiteAnalyticsDesc: "Wie oft diese Website besucht wird.",
+ // Die hier ist die, die tatsächlich angezeigt wird: die zusammengefasste Fahrplan-Zeile
+ // in ToolSelector kommt vom ersten Eintrag der Gruppe (siehe TOOL_REGISTRY), und das
+ // ist dieser — sie beschreibt deshalb die ganze Fähigkeit und nennt die Verkehrsmittel,
+ // weil das Zeilenlabel ("Fahrplan") bewusst gattungsneutral bleibt.
+ toolTransitSearchDesc: "Züge, Busse und Fähren in ganz Europa – Verbindungen und Abfahrten, mit Echtzeit, wo Betreiber sie liefern.",
+ toolTransitPlanDesc: "Plant eine Verbindung von Tür zu Tür – Züge, Busse, Fähren.",
+ toolTransitDeparturesDesc: "Die nächsten Abfahrten einer Haltestelle.",
// Zeilentitel für die zusammengefasste Zeile in ToolSelector (get_sitzungen +
// search_claims). Eigener Name statt "Bundestagssitzungen": kurz, ein Eigenname, den
// niemand nachschlagen muss — die Beschreibung darunter erklärt, was es tut.
capBundestag: "Bundestag",
+ // Zeilentitel für die zusammengefasste Zeile (search_stop + plan_journey +
+ // get_departures). Bewusst kein Eigenname wie bei capBundestag ("Bundestag" kennt jeder,
+ // "Transitous" niemand) und auch nicht "Fahrplan" — ein Zeitplan ist auch das Konzert-
+ // Programm; "Mit Zug & Bus" nennt die Verkehrsmittel, die Fähre nennt die Beschreibung.
+ capTransitous: "Mit Zug & Bus",
emptyState: "Starte eine Unterhaltung, indem du unten eine Nachricht eingibst.",
doorIntro: "Hallo — ich bin der KI-Assistent dieser Website.",
doorCan:
@@ -161,6 +176,7 @@ export default {
bundestaktSource: "Quelle: Bundestakt",
analyticsSource: "Quelle: Analytics",
braveSource: "Quelle: Brave Search",
+ transitousSource: "Quelle: Transitous",
errorPrefix: "Fehler:",
unknownError: "Unbekannter Fehler",
loading: "Lädt...",
diff --git a/website/locales/en.ts b/website/locales/en.ts
index 451a7ba63..a5303751a 100644
--- a/website/locales/en.ts
+++ b/website/locales/en.ts
@@ -99,6 +99,9 @@ export default {
toolWebSearch: "Web search",
toolFetchUrl: "Fetch URL",
toolSiteAnalytics: "Site analytics",
+ toolTransitSearch: "Find stops",
+ toolTransitPlan: "Plan journeys",
+ toolTransitDepartures: "Departures",
toolImageGenerationDesc: "Creates a picture from your description.",
// The one actually shown: the grouped Bundestag row's description comes from the first
// entry in that group (see TOOL_REGISTRY), and that is this one.
@@ -108,7 +111,19 @@ export default {
toolWebSearchDesc: "Looks up current information on the web.",
toolFetchUrlDesc: "Reads a page you link.",
toolSiteAnalyticsDesc: "How often this website is visited.",
+ // The one actually shown: the grouped timetable row's description comes from the first
+ // entry in that group (see TOOL_REGISTRY), so this describes the whole capability and
+ // names the modes — the row label stays deliberately generic.
+ toolTransitSearchDesc: "Trains, buses and ferries across Europe — journeys and departures, with live times where operators publish them.",
+ toolTransitPlanDesc: "Plans a door-to-door journey — trains, buses, ferries.",
+ toolTransitDeparturesDesc: "The next departures from a stop.",
capBundestag: "Bundestag",
+ // Row title for the collapsed timetable row (search_stop + plan_journey +
+ // get_departures). Deliberately not a proper name like capBundestag ("Bundestag" needs
+ // no lookup, "Transitous" needs one for everyone) and not "Timetables" either — a
+ // schedule is also a concert programme; "Train & bus" names the modes, the ferry is
+ // named by the description. The citation names the data source.
+ capTransitous: "Train & bus",
emptyState: "Start a conversation by typing a message below.",
// The greeting a first-time visitor sees from the default agent: what this is, what it
// assumes and costs, where it can be wrong (IDENTITY.md → welcoming, honest).
@@ -152,6 +167,7 @@ export default {
bundestaktSource: "Source: Bundestakt",
analyticsSource: "Source: Analytics",
braveSource: "Source: Brave Search",
+ transitousSource: "Source: Transitous",
errorPrefix: "Error:",
unknownError: "Unknown error",
loading: "Loading...",
diff --git a/website/prompts/tool-contract.md b/website/prompts/tool-contract.md
index 1095513a6..db23a56c4 100644
--- a/website/prompts/tool-contract.md
+++ b/website/prompts/tool-contract.md
@@ -1,6 +1,6 @@
---
name: tool-contract
-description: The assistant's base system prompt — the tool contract. Routing rules for the Bundestakt, analytics, site-content and web-research tools, the fetch_url injection defence, and date-relative questions.
+description: The assistant's base system prompt — the tool contract. Routing rules for the Bundestakt, transit, analytics, site-content and web-research tools, the fetch_url injection defence, and date-relative questions.
---
You are a helpful assistant. You answer from live tool data wherever a tool exists for it, and you say so plainly whenever it does not.
@@ -8,6 +8,7 @@ You are a helpful assistant. You answer from live tool data wherever a tool exis
## Tool routing
- **Bundestag sessions, speaking time, fact-checks of MP statements** — use `get_sitzungen` and `search_claims`. Call `get_sitzungen` without a slug first to find the right session, then again with its slug for the details. That data comes from bundestakt.de, an AI-assisted analysis of official transcripts and not an official record itself. Always link the `url` from the tool result in your answer.
+- **Journeys, departures and connections in Europe** — use `search_stop` first to resolve a named place to a stop id, then `plan_journey` or `get_departures` with that id. If several stops match, take the obvious hub or ask the user. All times come back in UTC — convert them to the user's local timezone before presenting. When a result has `realTime=false`, present its times as scheduled and say so. That data comes from transitous.org, schedules only: prices and booking are out of scope.
- **How this site or one of its posts is doing** — visitors, most-read pages, trends — use `get_analytics`. When its result sets `hasHistoric`, the window reaches into figures backfilled from a different tool that counted differently; say so instead of comparing the two eras as one number.
- **What this site itself says** — a blog post, a lecture, a project page — use `get_page`. Call it with the url when you already know it, such as /blog/36/, and otherwise without arguments first to list the pages. If that result sets `truncated`, call once more with a heading from its outline.
- **Anything current or off this site** — news, other people's writing, documentation — use `search_web`, and link the url of every result you rely on. To read one of those results in full, or a link the user gives you, use `fetch_url`.
diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx
index 252fc6314..5eb1beefc 100644
--- a/website/test/AssistantChat.test.tsx
+++ b/website/test/AssistantChat.test.tsx
@@ -22,6 +22,9 @@ const mockGenerateImage = vi.fn();
const {
mockFetchSitzungen,
mockFetchClaims,
+ mockFetchGeocode,
+ mockFetchStoptimes,
+ mockFetchPlan,
mockFetchStats,
mockFetchContentIndex,
mockFetchPageHtml,
@@ -31,6 +34,9 @@ const {
} = vi.hoisted(() => ({
mockFetchSitzungen: vi.fn(),
mockFetchClaims: vi.fn(),
+ mockFetchGeocode: vi.fn(),
+ mockFetchStoptimes: vi.fn(),
+ mockFetchPlan: vi.fn(),
mockFetchStats: vi.fn(),
mockFetchContentIndex: vi.fn(),
mockFetchPageHtml: vi.fn(),
@@ -125,6 +131,17 @@ vi.mock("../tools/bundestakt", async (importOriginal) => {
return { ...actual, fetchSitzungen: mockFetchSitzungen, fetchClaims: mockFetchClaims };
});
+// Same for transitous: real selectors, stubbed fetchers.
+vi.mock("../tools/transitous", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchGeocode: mockFetchGeocode,
+ fetchStoptimes: mockFetchStoptimes,
+ fetchPlan: mockFetchPlan,
+ };
+});
+
// Same treatment for analytics: real selector, stubbed fetcher.
vi.mock("../tools/analytics", async (importOriginal) => {
const actual = await importOriginal();
@@ -156,6 +173,9 @@ import { useWalletConnection } from "../hooks/useWalletConnection";
import { useAutoNetwork } from "../hooks/useAutoNetwork";
import sitzungenFixture from "./fixtures/bundestakt/sitzungen.json";
import claimsFixture from "./fixtures/bundestakt/claims.json";
+import transitousGeocodeFixture from "./fixtures/transitous/geocode.json";
+import transitousStoptimesFixture from "./fixtures/transitous/stoptimes.json";
+import transitousPlanFixture from "./fixtures/transitous/plan.json";
import { OWNER_SCOPES } from "../utils/getChain";
import { PaymentError } from "../utils/x402PaidFetch";
import { MAX_HOPS } from "../utils/toolLoop";
@@ -222,6 +242,9 @@ describe("AssistantChat", () => {
mockSwitchImageIfNeeded.mockResolvedValue(true);
mockFetchSitzungen.mockResolvedValue(sitzungenFixture);
mockFetchClaims.mockResolvedValue(claimsFixture);
+ mockFetchGeocode.mockResolvedValue(transitousGeocodeFixture);
+ mockFetchStoptimes.mockResolvedValue(transitousStoptimesFixture);
+ mockFetchPlan.mockResolvedValue(transitousPlanFixture);
mockFetchStats.mockResolvedValue(statsFixture());
mockFetchContentIndex.mockResolvedValue([{ url: "/blog/36/", title: "My static site got a tool loop" }]);
mockFetchSearch.mockResolvedValue({
@@ -777,6 +800,126 @@ describe("AssistantChat", () => {
expect(mockFetchSitzungen).toHaveBeenCalledTimes(1);
});
+ it("runs the Transitous search-then-plan chain silently and feeds the projection back", async () => {
+ // Free and read-only, like the Bundestakt lookups: no confirm card, no wallet gate.
+ mockSendMessage
+ .mockResolvedValueOnce(toolCallResponse("search_stop", { query: "Heidelberg Hbf" }))
+ .mockResolvedValueOnce(
+ toolCallResponse("plan_journey", { from: "de-DELFI_de:08221:1160", to: "it-trenitalia_01700:station" }),
+ )
+ .mockResolvedValueOnce(textResponse("The direct EC to Milano leaves at 20:07."));
+
+ renderWithQuery();
+ sendUserMessage("How do I get from Heidelberg Hbf to Milano Centrale tomorrow?");
+
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(3));
+ expect(screen.queryByRole("button", { name: /assistent\.toolConfirmGenerate/ })).not.toBeInTheDocument();
+ expect(mockFetchGeocode).toHaveBeenCalledTimes(1);
+ expect(mockFetchPlan).toHaveBeenCalledTimes(1);
+
+ // The tool turns carry the projected answers, not MOTIS's raw multi-hundred-KB reply.
+ const thirdConvo = mockSendMessage.mock.calls[2][0] as { role: string; content: string }[];
+ const toolResults = thirdConvo.filter((m) => m.role === "tool");
+ expect(toolResults).toHaveLength(2);
+ const stops = JSON.parse(toolResults[0].content) as { status: string; stops: { id: string }[] };
+ expect(stops.status).toBe("ok");
+ expect(stops.stops.length).toBeGreaterThan(0);
+ const journeys = JSON.parse(toolResults[1].content) as {
+ status: string;
+ itineraries: { legs: Record[] }[];
+ };
+ expect(journeys.status).toBe("ok");
+ expect(journeys.itineraries.length).toBeGreaterThan(0);
+ for (const leg of journeys.itineraries.flatMap((it) => it.legs)) {
+ expect(leg.legGeometry).toBeUndefined();
+ }
+ });
+
+ it("withdraws a failed Transitous tool but keeps its siblings on offer", async () => {
+ mockFetchPlan.mockRejectedValue(new Error("Failed to fetch"));
+ mockSendMessage
+ .mockResolvedValueOnce(toolCallResponse("plan_journey", { from: "a", to: "b" }))
+ .mockResolvedValueOnce(textResponse("I could not reach the timetable."));
+
+ renderWithQuery();
+ sendUserMessage("How do I get to Milano?");
+
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2));
+ const offered = (mockSendMessage.mock.calls[1][1] as { tools: { function: { name: string } }[] }).tools;
+ const names = offered.map((t) => t.function.name);
+ expect(names).not.toContain("plan_journey");
+ expect(names).toContain("search_stop");
+ expect(names).toContain("get_departures");
+ });
+
+ it("answers an empty Transitous argument with a recoverable bad_query, not a withdrawal", async () => {
+ // An empty required argument is the model's mistake to correct, not a malfunction of the
+ // tool: withdrawing search_stop would take away the one thing that fixes the next call.
+ mockSendMessage
+ .mockResolvedValueOnce(toolCallResponse("search_stop", {}))
+ .mockResolvedValueOnce(textResponse("Which station did you mean?"));
+
+ renderWithQuery();
+ sendUserMessage("When does the next train leave?");
+
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2));
+ expect(mockFetchGeocode).not.toHaveBeenCalled();
+
+ const secondConvo = mockSendMessage.mock.calls[1][0] as { role: string; content: string }[];
+ const toolResult = secondConvo.find((m) => m.role === "tool");
+ expect(JSON.parse(toolResult!.content)).toMatchObject({ status: "bad_query" });
+
+ const offered = (mockSendMessage.mock.calls[1][1] as { tools: { function: { name: string } }[] }).tools;
+ expect(offered.map((t) => t.function.name)).toContain("search_stop");
+ });
+
+ it("credits Transitous after a successful lookup, but not after a failed one", async () => {
+ // Transitous's usage policy asks for visible attribution, so this line is an obligation,
+ // and equally one not to show when nothing was actually retrieved.
+ mockSendMessage
+ .mockResolvedValueOnce(toolCallResponse("search_stop", { query: "Heidelberg Hbf" }))
+ .mockResolvedValueOnce(textResponse("Heidelberg Hbf is Heidelberg's main station."));
+
+ const { unmount } = renderWithQuery();
+ sendUserMessage("Where is Heidelberg Hbf?");
+ await waitFor(() => expect(screen.getByText("assistent.transitousSource")).toBeInTheDocument());
+ unmount();
+
+ vi.clearAllMocks();
+ mockSwitchIfNeeded.mockResolvedValue(true);
+ mockFetchGeocode.mockRejectedValue(new Error("Failed to fetch"));
+ mockSendMessage
+ .mockResolvedValueOnce(toolCallResponse("search_stop", { query: "Heidelberg Hbf" }))
+ .mockResolvedValueOnce(textResponse("I could not reach the timetable."));
+
+ renderWithQuery();
+ sendUserMessage("Where is Heidelberg Hbf?");
+
+ await waitFor(() => expect(screen.getByText("I could not reach the timetable.")).toBeInTheDocument());
+ expect(screen.queryByText("assistent.transitousSource")).not.toBeInTheDocument();
+ });
+
+ it("switching the Transitous row off persists and reaches the next request", async () => {
+ renderWithQuery();
+
+ // search_stop, plan_journey and get_departures present as one merged "Train & bus" row
+ // (TOOL_REGISTRY's `group`, whose locale value is the row label), so one click
+ // switches off all three wire names at once.
+ fireEvent.click(screen.getAllByLabelText("Train & bus")[0]);
+ expect(window.localStorage.getItem("x402-chat-disabled-tools")).toBe(
+ "search_stop,plan_journey,get_departures",
+ );
+
+ sendUserMessage("Hi");
+ await waitFor(() => expect(mockSendMessage).toHaveBeenCalledOnce());
+ const names = (mockSendMessage.mock.calls[0][1] as { tools: { function: { name: string } }[] }).tools.map(
+ (t) => t.function.name,
+ );
+ expect(names).not.toContain("search_stop");
+ expect(names).not.toContain("plan_journey");
+ expect(names).not.toContain("get_departures");
+ });
+
it("keeps the image tool on offer when a Bundestakt lookup fails", async () => {
// The regression this guards: a single shared `toolFailed` flag would have disabled
// generate_image for the rest of the turn because an unrelated, free lookup failed.
@@ -801,13 +944,18 @@ describe("AssistantChat", () => {
// be sent as `tools: []`. "Nothing left to offer" has to mean the key is absent.
mockFetchSitzungen.mockRejectedValue(new Error("down"));
mockFetchClaims.mockRejectedValue(new Error("down"));
+ mockFetchGeocode.mockRejectedValue(new Error("down"));
+ mockFetchStoptimes.mockRejectedValue(new Error("down"));
+ mockFetchPlan.mockRejectedValue(new Error("down"));
// get_page, search_web and fetch_url are switched off rather than failed: every one of their
// failures is recoverable by design — a different url, a different query, a different section
// — so they are never withdrawn, and "everything on offer has failed" can only be reached
// with them off the table to begin with.
window.localStorage.setItem("x402-chat-disabled-tools", "get_page,search_web,fetch_url");
- // All three in one hop: the two lookups fail on their own, the image tool via a cancel.
+ // All six in one hop: the five lookups fail on their own, the image tool via a cancel.
+ // The Transitous calls carry well-formed arguments, because an empty one would be a
+ // recoverable bad_query answer, not a failure — and the tool would stay on offer.
mockSendMessage.mockResolvedValue({
choices: [
{
@@ -818,6 +966,9 @@ describe("AssistantChat", () => {
{ id: "a", type: "function", function: { name: "get_sitzungen", arguments: "{}" } },
{ id: "b", type: "function", function: { name: "search_claims", arguments: "{}" } },
{ id: "c", type: "function", function: { name: "generate_image", arguments: "{}" } },
+ { id: "d", type: "function", function: { name: "search_stop", arguments: '{"query":"x"}' } },
+ { id: "e", type: "function", function: { name: "plan_journey", arguments: '{"from":"a","to":"b"}' } },
+ { id: "f", type: "function", function: { name: "get_departures", arguments: '{"stop_id":"x"}' } },
],
},
finish_reason: "tool_calls",
diff --git a/website/test/fixtures/transitous/geocode.json b/website/test/fixtures/transitous/geocode.json
new file mode 100644
index 000000000..c9b4bb187
--- /dev/null
+++ b/website/test/fixtures/transitous/geocode.json
@@ -0,0 +1,554 @@
+[
+ {
+ "type": "STOP",
+ "name": "Heidelberg Hbf",
+ "id": "de-DELFI_de:08221:1160",
+ "lat": 49.40357,
+ "lon": 8.675442000000002,
+ "country": "DE",
+ "zip": "69115",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Karlsruhe",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Heidelberg",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Weststadt",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -32.124732971191406,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ],
+ "importance": 0.003857618197798729
+ },
+ {
+ "type": "PLACE",
+ "category": "hotel_16",
+ "name": "ibis Heidelberg Hauptbahnhof",
+ "id": "way/[87645977]",
+ "lat": 49.4036051,
+ "lon": 8.6767446,
+ "country": "DE",
+ "zip": "69115",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Karlsruhe",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Heidelberg",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Weststadt",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -27.5
+ },
+ {
+ "type": "STOP",
+ "name": "Bahnstadt, Hauptbahnhof Süd",
+ "id": "de-DELFI_de:08221:4272:1:TrRiO",
+ "lat": 49.40181,
+ "lon": 8.674195,
+ "country": "DE",
+ "zip": "69115",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Karlsruhe",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Heidelberg",
+ "adminLevel": 6,
+ "matched": true,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Bahnstadt",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -26.631120681762695,
+ "modes": [
+ "TRAM",
+ "BUS"
+ ],
+ "importance": 0.000045373457396635786
+ },
+ {
+ "type": "PLACE",
+ "category": "hotel_16",
+ "name": "Meininger Heidelberg Hauptbahnhof",
+ "id": "way/[753509904]",
+ "lat": 49.3987481,
+ "lon": 8.677719,
+ "country": "DE",
+ "zip": "69115",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Karlsruhe",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Heidelberg",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Weststadt",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -26.25
+ },
+ {
+ "type": "PLACE",
+ "category": "none",
+ "name": "Heidelberg Hauptbahnhof Ost",
+ "id": "node/[5795035409]",
+ "lat": 49.4044187,
+ "lon": 8.6780409,
+ "country": "DE",
+ "zip": "69115",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Karlsruhe",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Heidelberg",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Bergheim",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -24.75
+ },
+ {
+ "type": "STOP",
+ "name": "Hauptbahnhof Süd",
+ "id": "at-Railway-Current-Reference-Data-2026_de:09162:100:11:11",
+ "lat": 48.13988,
+ "lon": 11.557491,
+ "country": "DE",
+ "zip": "80335",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Bayern",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Oberbayern",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "München",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Ludwigsvorstadt-Isarvorstadt",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -18.81806755065918,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "SUBWAY",
+ "TRAM",
+ "BUS"
+ ],
+ "importance": 0.021757638081908226
+ },
+ {
+ "type": "STOP",
+ "name": "Hauptbahnhof/ZOB",
+ "id": "at-Railway-Current-Reference-Data-2026_de:02000:10950:11:1",
+ "lat": 53.552475,
+ "lon": 10.008094999999999,
+ "level": -1,
+ "country": "DE",
+ "zip": "20099",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Hamburg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Hamburg-Mitte",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "St. Georg",
+ "adminLevel": 10,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -18.813016891479492,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "SUBWAY",
+ "BUS"
+ ],
+ "importance": 0.021419377997517586
+ },
+ {
+ "type": "STOP",
+ "name": "Hauptbahnhof (tief)",
+ "id": "at-Railway-Current-Reference-Data-2026_de:08111:6115:1:1",
+ "lat": 48.784748,
+ "lon": 9.183216999999999,
+ "country": "DE",
+ "zip": "70173",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Stuttgart",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Stuttgart",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Stuttgart-Mitte",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Hauptbahnhof",
+ "adminLevel": 10,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -18.504125595092773,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "SUBWAY",
+ "TRAM",
+ "BUS"
+ ],
+ "importance": 0.019253002479672432
+ },
+ {
+ "type": "STOP",
+ "name": "Wien Hauptbahnhof",
+ "id": "at-Railway-Current-Reference-Data-2026_Pat:49:1349",
+ "lat": 48.185190000000006,
+ "lon": 16.376413,
+ "level": 1,
+ "country": "AT",
+ "zip": "1100",
+ "tz": "Europe/Vienna",
+ "areas": [
+ {
+ "name": "Österreich",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Wien",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Favoriten",
+ "adminLevel": 9,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Katastralgemeinde Favoriten",
+ "adminLevel": 10,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -18.438060760498047,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "SUBWAY",
+ "TRAM",
+ "BUS"
+ ],
+ "importance": 0.017061332240700722
+ },
+ {
+ "type": "STOP",
+ "name": "Ulm Hauptbahnhof",
+ "id": "at-Railway-Current-Reference-Data-2026_de:08421:1008:31:1",
+ "lat": 48.399345000000004,
+ "lon": 9.982933000000001,
+ "country": "DE",
+ "zip": "89073",
+ "tz": "Europe/Berlin",
+ "areas": [
+ {
+ "name": "Deutschland",
+ "adminLevel": 2,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Baden-Württemberg",
+ "adminLevel": 4,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Regierungsbezirk Tübingen",
+ "adminLevel": 5,
+ "matched": false,
+ "unique": false,
+ "default": false
+ },
+ {
+ "name": "Ulm",
+ "adminLevel": 6,
+ "matched": false,
+ "unique": true,
+ "default": true
+ },
+ {
+ "name": "Mitte",
+ "adminLevel": 10,
+ "matched": false,
+ "unique": false,
+ "default": false
+ }
+ ],
+ "score": -18.104284286499023,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "TRAM",
+ "BUS"
+ ],
+ "importance": 0.005886611994355917
+ }
+]
\ No newline at end of file
diff --git a/website/test/fixtures/transitous/plan.json b/website/test/fixtures/transitous/plan.json
new file mode 100644
index 000000000..d109ec472
--- /dev/null
+++ b/website/test/fixtures/transitous/plan.json
@@ -0,0 +1,1757 @@
+{
+ "requestParameters": {},
+ "from": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160",
+ "importance": 0.003857618197798729,
+ "lat": 49.40357,
+ "lon": 8.675442000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "to": {
+ "name": "MILANO CENTRALE",
+ "stopId": "it-trenitalia_01700:station",
+ "importance": 0.010967501439154148,
+ "lat": 45.486340999999996,
+ "lon": 9.204544,
+ "level": 2,
+ "tz": "Europe/Rome",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "direct": [],
+ "itineraries": [
+ {
+ "duration": 27420,
+ "startTime": "2026-10-15T05:13:00Z",
+ "endTime": "2026-10-15T12:50:00Z",
+ "transfers": 2,
+ "id": "Cq4BCgJTMxIiMjAyNjEwMTVfMDY6MjFfZGUtREVMRklfMzQ0MDQ3ODQyMhoaZGUtREVMRklfZGU6MDgyMjE6MTE2MDozOjMh+NueILGzSEAp3pIcsKtZIUAyGmRlLURFTEZJX2RlOjA4MjIyOjI0MTc6MToxOT3QCgxZvUhAQeMBZVOu8CBASNzBwdYGUJzJwdYGWghTVUJVUkJBTmABaQAAAAAAAPC/cQAAAAAAAAAACoIBGhpkZS1ERUxGSV9kZTowODIyMjoyNDE3OjE6MSE90AoMWb1IQCnjAWVTrvAgQDIaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQ5NSbEXFK9SEBBcQM+P4zwIEBIlMrB1gZQjMvB1gZaBFdBTEtgAWkAAAAAAAAAAHEAAAAAAAAAAAq3AQoHSUNFIDI3NRIiMjAyNjEwMTVfMDY6NTBfZGUtREVMRklfMzQ0MDY3OTgyMxoaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQhNSbEXFK9SEApcQM+P4zwIEAyGGRlLURFTEZJX2NoOjE6c2xvaWQ6MzAwMDmKCP8iaLBHQEFB0NGqlhQhQEj8zMHWBlDErcLWBloOSElHSFNQRUVEX1JBSUxgAWkAAAAAAAAAAHEAAAAAAAAAAAp9GhhkZS1ERUxGSV9jaDoxOnNsb2lkOjMwMDAhigj/ImiwR0ApQdDRqpYUIUAyF2l0LXRyZW5pdGFsaWFfODUwMDAzMDAwOT4HliNksEdAQRSuR+F6FCFASJCswtYGULyuwtYGWgRXQUxLYAFpAAAAAAAAAABxAAAAAAAAAAAK0gEKAkVDEksyMDI2MTAxNV8xMTozM19pdC10cmVuaXRhbGlhXzAwODNfNjAwODNfMF92XzYtMTktNEEyRS0wMDgzX3ZfNi0xOS00QTJFLTAwODMaF2l0LXRyZW5pdGFsaWFfODUwMDAzMDAwIT4HliNksEdAKRSuR+F6FCFAMhNpdC10cmVuaXRhbGlhXzAxNzAwORraAGxAvkZAQaEvvf25aCJASMy7wtYGUPiXw9YGWg1MT05HX0RJU1RBTkNFYAFpAAAAAAAAAABxAAAAAAAAAEA=",
+ "legs": [
+ {
+ "mode": "METRO",
+ "from": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:3:3",
+ "parentId": "de-DELFI_de:08221:1160_G",
+ "importance": 0.003857618197798729,
+ "lat": 49.403842999999995,
+ "lon": 8.675138,
+ "level": -1,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T05:13:00Z",
+ "scheduledDeparture": "2026-10-15T05:13:00Z",
+ "scheduledTrack": "3",
+ "track": "3",
+ "description": "Gleis 2, Gleis 3",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "to": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:1:1",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479279999999996,
+ "lon": 8.470080000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:31:00Z",
+ "scheduledArrival": "2026-10-15T05:29:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "duration": 1080,
+ "startTime": "2026-10-15T05:13:00Z",
+ "endTime": "2026-10-15T05:31:00Z",
+ "scheduledStartTime": "2026-10-15T05:13:00Z",
+ "scheduledEndTime": "2026-10-15T05:29:00Z",
+ "realTime": true,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "Mannheim Hbf",
+ "tripFrom": {
+ "name": "Karlsruhe Hauptbahnhof",
+ "stopId": "de-DELFI_de:08212:90:4:6",
+ "parentId": "de-DELFI_de:08212:90",
+ "importance": 0.010275337845087051,
+ "lat": 48.993366,
+ "lon": 8.401277999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T04:21:00Z",
+ "scheduledDeparture": "2026-10-15T04:21:00Z",
+ "scheduledTrack": "6",
+ "track": "6",
+ "description": "Gleis 5+6",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Germersheim",
+ "stopId": "de-DELFI_de:07334:32637:4:5",
+ "importance": 0.0008799014030955732,
+ "lat": 49.225640000000006,
+ "lon": 8.364806000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T06:12:00Z",
+ "scheduledArrival": "2026-10-15T06:12:00Z",
+ "scheduledTrack": "5",
+ "track": "5",
+ "description": "Gleis 4+5",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS"
+ ]
+ },
+ "routeId": "de-DELFI_de:rlp:s3:_109",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "fddd04",
+ "routeTextColor": "231f20",
+ "routeType": 109,
+ "agencyName": "DB Regio AG Mitte Region Südwest",
+ "agencyUrl": "",
+ "agencyFareUrl": "",
+ "agencyId": "10449",
+ "tripId": "20261015_06:21_de-DELFI_3440478422",
+ "routeShortName": "S3",
+ "routeLongName": "",
+ "tripShortName": "038310",
+ "displayName": "S3",
+ "cancelled": false,
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:40919793:40919824",
+ "intermediateStops": [
+ {
+ "name": "HD-Pfaffengrund/Wieblingen",
+ "stopId": "de-DELFI_de:08221:1454:1:1",
+ "parentId": "de-DELFI_de:08221:1454",
+ "importance": 0.000958796648774296,
+ "lat": 49.41197,
+ "lon": 8.641532999999999,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:16:00Z",
+ "departure": "2026-10-15T05:17:00Z",
+ "scheduledArrival": "2026-10-15T05:16:00Z",
+ "scheduledDeparture": "2026-10-15T05:17:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS",
+ "ODM"
+ ]
+ },
+ {
+ "name": "MA-Friedrichsfeld, Südbahnhof",
+ "stopId": "de-DELFI_de:08222:2334:1:1",
+ "parentId": "de-DELFI_de:08222:2334",
+ "importance": 0.000713150657247752,
+ "lat": 49.438103000000005,
+ "lon": 8.572569000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:21:00Z",
+ "departure": "2026-10-15T05:22:00Z",
+ "scheduledArrival": "2026-10-15T05:21:00Z",
+ "scheduledDeparture": "2026-10-15T05:22:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Zug->Mannheim",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "SUBURBAN",
+ "BUS",
+ "ODM"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ },
+ {
+ "mode": "WALK",
+ "from": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:1:1",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479279999999996,
+ "lon": 8.470080000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:31:00Z",
+ "departure": "2026-10-15T05:31:00Z",
+ "scheduledArrival": "2026-10-15T05:29:00Z",
+ "scheduledDeparture": "2026-10-15T05:31:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1",
+ "vertexType": "TRANSIT",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "to": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:5:4",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479076,
+ "lon": 8.46982,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:33:00Z",
+ "scheduledArrival": "2026-10-15T05:33:00Z",
+ "scheduledTrack": "4",
+ "track": "4",
+ "description": "Gleis 4, Gleis 5",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "duration": 120,
+ "startTime": "2026-10-15T05:31:00Z",
+ "endTime": "2026-10-15T05:33:00Z",
+ "scheduledStartTime": "2026-10-15T05:31:00Z",
+ "scheduledEndTime": "2026-10-15T05:33:00Z",
+ "realTime": false,
+ "scheduled": false,
+ "distance": 77,
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "steps": [],
+ "intermediateStops": []
+ },
+ {
+ "mode": "HIGHSPEED_RAIL",
+ "from": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:5:4",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479076,
+ "lon": 8.46982,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T05:37:00Z",
+ "scheduledDeparture": "2026-10-15T05:37:00Z",
+ "scheduledTrack": "4",
+ "track": "4",
+ "description": "Gleis 4, Gleis 5",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "to": {
+ "name": "Zürich HB",
+ "stopId": "de-DELFI_ch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378178000000005,
+ "lon": 8.540211999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:00:00Z",
+ "scheduledArrival": "2026-10-15T09:03:00Z",
+ "track": "10",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "duration": 12180,
+ "startTime": "2026-10-15T05:37:00Z",
+ "endTime": "2026-10-15T09:00:00Z",
+ "scheduledStartTime": "2026-10-15T05:37:00Z",
+ "scheduledEndTime": "2026-10-15T09:03:00Z",
+ "realTime": true,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "Zürich HB",
+ "tripFrom": {
+ "name": "Basel SBB",
+ "stopId": "de-DELFI_ch:1:sloid:10",
+ "importance": 0.03557715564966202,
+ "lat": 47.547413,
+ "lon": 7.589563000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T08:07:00Z",
+ "scheduledDeparture": "2026-10-15T08:07:00Z",
+ "track": "4",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Basel SBB",
+ "stopId": "de-DELFI_ch:1:sloid:10",
+ "importance": 0.03557715564966202,
+ "lat": 47.547413,
+ "lon": 7.589563000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T07:47:00Z",
+ "scheduledArrival": "2026-10-15T07:48:00Z",
+ "track": "4",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "routeId": "de-DELFI_162290_101",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeType": 101,
+ "agencyName": "DB Fernverkehr AG",
+ "agencyUrl": "",
+ "agencyFareUrl": "",
+ "agencyId": "12681",
+ "tripId": "20261015_06:50_de-DELFI_3440679823",
+ "routeShortName": "ICE 275",
+ "routeLongName": "",
+ "tripShortName": "ICE 275",
+ "displayName": "ICE 275",
+ "cancelled": false,
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:39893110:39893116",
+ "intermediateStops": [
+ {
+ "name": "Karlsruhe Hauptbahnhof",
+ "stopId": "de-DELFI_de:08212:90:2:2",
+ "parentId": "de-DELFI_de:08212:90",
+ "importance": 0.010275337845087051,
+ "lat": 48.99373200000001,
+ "lon": 8.401070999999998,
+ "level": 1,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:59:00Z",
+ "departure": "2026-10-15T06:01:00Z",
+ "scheduledArrival": "2026-10-15T05:59:00Z",
+ "scheduledDeparture": "2026-10-15T06:01:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "description": "Gleis 1+2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ {
+ "name": "Offenburg Bahnhof",
+ "stopId": "de-DELFI_de:08317:14506:1:1",
+ "parentId": "de-DELFI_de:08317:14506",
+ "importance": 0.002785325516015291,
+ "lat": 48.47649,
+ "lon": 7.946129000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T06:29:00Z",
+ "departure": "2026-10-15T06:30:00Z",
+ "scheduledArrival": "2026-10-15T06:29:00Z",
+ "scheduledDeparture": "2026-10-15T06:30:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1+2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "BUS"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ },
+ {
+ "mode": "WALK",
+ "from": {
+ "name": "Zürich HB",
+ "stopId": "de-DELFI_ch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378178000000005,
+ "lon": 8.540211999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:00:00Z",
+ "departure": "2026-10-15T09:00:00Z",
+ "scheduledArrival": "2026-10-15T09:03:00Z",
+ "scheduledDeparture": "2026-10-15T09:00:00Z",
+ "track": "10",
+ "vertexType": "TRANSIT",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "to": {
+ "name": "ZUERICH HB",
+ "stopId": "it-trenitalia_850003000",
+ "parentId": "it-trenitalia_850003000:station",
+ "importance": 0.07083380967378616,
+ "lat": 47.378056,
+ "lon": 8.54,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:05:00Z",
+ "scheduledArrival": "2026-10-15T09:05:00Z",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "duration": 300,
+ "startTime": "2026-10-15T09:00:00Z",
+ "endTime": "2026-10-15T09:05:00Z",
+ "scheduledStartTime": "2026-10-15T09:00:00Z",
+ "scheduledEndTime": "2026-10-15T09:05:00Z",
+ "realTime": false,
+ "scheduled": false,
+ "distance": 117,
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "steps": [],
+ "intermediateStops": []
+ },
+ {
+ "mode": "LONG_DISTANCE",
+ "from": {
+ "name": "ZUERICH HB",
+ "stopId": "it-trenitalia_850003000",
+ "parentId": "it-trenitalia_850003000:station",
+ "importance": 0.07083380967378616,
+ "lat": 47.378056,
+ "lon": 8.54,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T09:33:00Z",
+ "scheduledDeparture": "2026-10-15T09:33:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "to": {
+ "name": "MILANO CENTRALE",
+ "stopId": "it-trenitalia_01700",
+ "parentId": "it-trenitalia_01700:station",
+ "importance": 0.010967501439154148,
+ "lat": 45.486340999999996,
+ "lon": 9.204544,
+ "level": 2,
+ "tz": "Europe/Rome",
+ "arrival": "2026-10-15T12:50:00Z",
+ "scheduledArrival": "2026-10-15T12:50:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "duration": 11820,
+ "startTime": "2026-10-15T09:33:00Z",
+ "endTime": "2026-10-15T12:50:00Z",
+ "scheduledStartTime": "2026-10-15T09:33:00Z",
+ "scheduledEndTime": "2026-10-15T12:50:00Z",
+ "realTime": false,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "MILANO CENTRALE",
+ "tripFrom": {
+ "name": "ZUERICH HB",
+ "stopId": "it-trenitalia_850003000",
+ "parentId": "it-trenitalia_850003000:station",
+ "importance": 0.07083380967378616,
+ "lat": 47.378056,
+ "lon": 8.54,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T09:33:00Z",
+ "scheduledDeparture": "2026-10-15T09:33:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "tripTo": {
+ "name": "MILANO CENTRALE",
+ "stopId": "it-trenitalia_01700",
+ "parentId": "it-trenitalia_01700:station",
+ "importance": 0.010967501439154148,
+ "lat": 45.486340999999996,
+ "lon": 9.204544,
+ "level": 2,
+ "tz": "Europe/Rome",
+ "arrival": "2026-10-15T12:50:00Z",
+ "scheduledArrival": "2026-10-15T12:50:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "routeId": "it-trenitalia_60083_7cf9622d2ca9dab7",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "1f6fb2",
+ "routeTextColor": "ffffff",
+ "routeType": 102,
+ "agencyName": "TRENITALIA",
+ "agencyUrl": "http://www.trenitalia.com",
+ "agencyFareUrl": "",
+ "agencyId": "TRENITALIA",
+ "tripId": "20261015_11:33_it-trenitalia_0083_60083_0_v_6-19-4A2E-0083_v_6-19-4A2E-0083",
+ "routeShortName": "EC",
+ "routeLongName": "Eurocity",
+ "tripShortName": "19",
+ "displayName": "EC",
+ "cancelled": false,
+ "source": "it_trenitalia.gtfs.zip/stop_times.txt:116621:116628",
+ "intermediateStops": [
+ {
+ "name": "ZUG(CH)",
+ "stopId": "it-trenitalia_850002204",
+ "parentId": "it-trenitalia_850002204:station",
+ "importance": 0.0008505891892127693,
+ "lat": 47.173611,
+ "lon": 8.515277999999999,
+ "level": 1,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:59:00Z",
+ "departure": "2026-10-15T10:00:00Z",
+ "scheduledArrival": "2026-10-15T09:59:00Z",
+ "scheduledDeparture": "2026-10-15T10:00:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "REGIONAL_RAIL"
+ ]
+ },
+ {
+ "name": "ARTH-GOLDAU",
+ "stopId": "it-trenitalia_850005004",
+ "parentId": "it-trenitalia_850005004:station",
+ "importance": 0.010756864212453365,
+ "lat": 47.048888999999996,
+ "lon": 8.549444000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T10:16:00Z",
+ "departure": "2026-10-15T10:18:00Z",
+ "scheduledArrival": "2026-10-15T10:16:00Z",
+ "scheduledDeparture": "2026-10-15T10:18:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS",
+ "FUNICULAR"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ }
+ ]
+ },
+ {
+ "duration": 25440,
+ "startTime": "2026-10-15T05:13:00Z",
+ "endTime": "2026-10-15T12:17:00Z",
+ "transfers": 3,
+ "id": "Cq4BCgJTMxIiMjAyNjEwMTVfMDY6MjFfZGUtREVMRklfMzQ0MDQ3ODQyMhoaZGUtREVMRklfZGU6MDgyMjE6MTE2MDozOjMh+NueILGzSEAp3pIcsKtZIUAyGmRlLURFTEZJX2RlOjA4MjIyOjI0MTc6MToxOT3QCgxZvUhAQeMBZVOu8CBASNzBwdYGUJzJwdYGWghTVUJVUkJBTmABaQAAAAAAAPC/cQAAAAAAAAAACoIBGhpkZS1ERUxGSV9kZTowODIyMjoyNDE3OjE6MSE90AoMWb1IQCnjAWVTrvAgQDIaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQ5NSbEXFK9SEBBcQM+P4zwIEBIlMrB1gZQjMvB1gZaBFdBTEtgAWkAAAAAAAAAAHEAAAAAAAAAAAq3AQoHSUNFIDI3NRIiMjAyNjEwMTVfMDY6NTBfZGUtREVMRklfMzQ0MDY3OTgyMxoaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQhNSbEXFK9SEApcQM+P4zwIEAyGGRlLURFTEZJX2NoOjE6c2xvaWQ6MzAwMDmKCP8iaLBHQEFB0NGqlhQhQEj8zMHWBlDErcLWBloOSElHSFNQRUVEX1JBSUxgAWkAAAAAAAAAAHEAAAAAAAAAAAqVARoYZGUtREVMRklfY2g6MTpzbG9pZDozMDAwIYoI/yJosEdAKUHQ0aqWFCFAMi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDozMDAwOjU6ODmwHCEDebBHQEGBQGfSphIhQEiQrMLWBlC8rsLWBloEV0FMS2ABaQAAAAAAAAAAcQAAAAAAAAAACvYBCgNJQzISQzIwMjYxMDE1XzExOjA1X2NoLW9wZW50cmFuc3BvcnRkYXRhc3dpc3MyNl8ub2pwLTkxLTItRy4xLlRBLjE2NC5qMjYaL2NoLW9wZW50cmFuc3BvcnRkYXRhc3dpc3MyNl9jaDoxOnNsb2lkOjMwMDA6NTo4IbAcIQN5sEdAKYFAZ9KmEiFAMi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDo1MzAwOjI6Mjkq/1peuQBHQEEff2lRn+QhQEi8rsLWBlC448LWBloNTE9OR19ESVNUQU5DRWABaQAAAAAAAAAACpoBGi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDo1MzAwOjI6MiEq/1peuQBHQCkff2lRn+QhQDIvY2gtb3BlbnRyYW5zcG9ydGRhdGFzd2lzczI2X2NoOjE6c2xvaWQ6NTMwMDoxOjE5ct9qnbgAR0BBSMfVyK7kIUBIuOPC1gZQqOXC1gZaBFdBTEtgAQrqAQoEUkU4MBJCMjAyNjEwMTVfMTI6MjJfY2gtb3BlbnRyYW5zcG9ydGRhdGFzd2lzczI2Xy5vanAtOTEtODAuMS5UQS45MTUuajI2Gi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDo1MzAwOjE6MSFy32qduABHQClIx9XIruQhQDIjY2gtb3BlbnRyYW5zcG9ydGRhdGFzd2lzczI2XzgzMDE3MDA59dVVgVq+RkBBqwDfbd5oIkBIqOXC1gZQvIjD1gZaDVJFR0lPTkFMX1JBSUxgAXEAAAAAAAAAQA==",
+ "legs": [
+ {
+ "mode": "METRO",
+ "from": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:3:3",
+ "parentId": "de-DELFI_de:08221:1160_G",
+ "importance": 0.003857618197798729,
+ "lat": 49.403842999999995,
+ "lon": 8.675138,
+ "level": -1,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T05:13:00Z",
+ "scheduledDeparture": "2026-10-15T05:13:00Z",
+ "scheduledTrack": "3",
+ "track": "3",
+ "description": "Gleis 2, Gleis 3",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "to": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:1:1",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479279999999996,
+ "lon": 8.470080000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:31:00Z",
+ "scheduledArrival": "2026-10-15T05:29:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "duration": 1080,
+ "startTime": "2026-10-15T05:13:00Z",
+ "endTime": "2026-10-15T05:31:00Z",
+ "scheduledStartTime": "2026-10-15T05:13:00Z",
+ "scheduledEndTime": "2026-10-15T05:29:00Z",
+ "realTime": true,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "Mannheim Hbf",
+ "tripFrom": {
+ "name": "Karlsruhe Hauptbahnhof",
+ "stopId": "de-DELFI_de:08212:90:4:6",
+ "parentId": "de-DELFI_de:08212:90",
+ "importance": 0.010275337845087051,
+ "lat": 48.993366,
+ "lon": 8.401277999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T04:21:00Z",
+ "scheduledDeparture": "2026-10-15T04:21:00Z",
+ "scheduledTrack": "6",
+ "track": "6",
+ "description": "Gleis 5+6",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Germersheim",
+ "stopId": "de-DELFI_de:07334:32637:4:5",
+ "importance": 0.0008799014030955732,
+ "lat": 49.225640000000006,
+ "lon": 8.364806000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T06:12:00Z",
+ "scheduledArrival": "2026-10-15T06:12:00Z",
+ "scheduledTrack": "5",
+ "track": "5",
+ "description": "Gleis 4+5",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS"
+ ]
+ },
+ "routeId": "de-DELFI_de:rlp:s3:_109",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "fddd04",
+ "routeTextColor": "231f20",
+ "routeType": 109,
+ "agencyName": "DB Regio AG Mitte Region Südwest",
+ "agencyUrl": "",
+ "agencyFareUrl": "",
+ "agencyId": "10449",
+ "tripId": "20261015_06:21_de-DELFI_3440478422",
+ "routeShortName": "S3",
+ "routeLongName": "",
+ "tripShortName": "038310",
+ "displayName": "S3",
+ "cancelled": false,
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:40919793:40919824",
+ "intermediateStops": [
+ {
+ "name": "HD-Pfaffengrund/Wieblingen",
+ "stopId": "de-DELFI_de:08221:1454:1:1",
+ "parentId": "de-DELFI_de:08221:1454",
+ "importance": 0.000958796648774296,
+ "lat": 49.41197,
+ "lon": 8.641532999999999,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:16:00Z",
+ "departure": "2026-10-15T05:17:00Z",
+ "scheduledArrival": "2026-10-15T05:16:00Z",
+ "scheduledDeparture": "2026-10-15T05:17:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS",
+ "ODM"
+ ]
+ },
+ {
+ "name": "MA-Friedrichsfeld, Südbahnhof",
+ "stopId": "de-DELFI_de:08222:2334:1:1",
+ "parentId": "de-DELFI_de:08222:2334",
+ "importance": 0.000713150657247752,
+ "lat": 49.438103000000005,
+ "lon": 8.572569000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:21:00Z",
+ "departure": "2026-10-15T05:22:00Z",
+ "scheduledArrival": "2026-10-15T05:21:00Z",
+ "scheduledDeparture": "2026-10-15T05:22:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Zug->Mannheim",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "SUBURBAN",
+ "BUS",
+ "ODM"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ },
+ {
+ "mode": "WALK",
+ "from": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:1:1",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479279999999996,
+ "lon": 8.470080000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:31:00Z",
+ "departure": "2026-10-15T05:31:00Z",
+ "scheduledArrival": "2026-10-15T05:29:00Z",
+ "scheduledDeparture": "2026-10-15T05:31:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1",
+ "vertexType": "TRANSIT",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "to": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:5:4",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479076,
+ "lon": 8.46982,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:33:00Z",
+ "scheduledArrival": "2026-10-15T05:33:00Z",
+ "scheduledTrack": "4",
+ "track": "4",
+ "description": "Gleis 4, Gleis 5",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "duration": 120,
+ "startTime": "2026-10-15T05:31:00Z",
+ "endTime": "2026-10-15T05:33:00Z",
+ "scheduledStartTime": "2026-10-15T05:31:00Z",
+ "scheduledEndTime": "2026-10-15T05:33:00Z",
+ "realTime": false,
+ "scheduled": false,
+ "distance": 77,
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "steps": [],
+ "intermediateStops": []
+ },
+ {
+ "mode": "HIGHSPEED_RAIL",
+ "from": {
+ "name": "Mannheim, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08222:2417:5:4",
+ "parentId": "de-DELFI_de:08222:2417",
+ "importance": 0.0067779431119561195,
+ "lat": 49.479076,
+ "lon": 8.46982,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T05:37:00Z",
+ "scheduledDeparture": "2026-10-15T05:37:00Z",
+ "scheduledTrack": "4",
+ "track": "4",
+ "description": "Gleis 4, Gleis 5",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "to": {
+ "name": "Zürich HB",
+ "stopId": "de-DELFI_ch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378178000000005,
+ "lon": 8.540211999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:00:00Z",
+ "scheduledArrival": "2026-10-15T09:03:00Z",
+ "track": "10",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "duration": 12180,
+ "startTime": "2026-10-15T05:37:00Z",
+ "endTime": "2026-10-15T09:00:00Z",
+ "scheduledStartTime": "2026-10-15T05:37:00Z",
+ "scheduledEndTime": "2026-10-15T09:03:00Z",
+ "realTime": true,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "Zürich HB",
+ "tripFrom": {
+ "name": "Basel SBB",
+ "stopId": "de-DELFI_ch:1:sloid:10",
+ "importance": 0.03557715564966202,
+ "lat": 47.547413,
+ "lon": 7.589563000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T08:07:00Z",
+ "scheduledDeparture": "2026-10-15T08:07:00Z",
+ "track": "4",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Basel SBB",
+ "stopId": "de-DELFI_ch:1:sloid:10",
+ "importance": 0.03557715564966202,
+ "lat": 47.547413,
+ "lon": 7.589563000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T07:47:00Z",
+ "scheduledArrival": "2026-10-15T07:48:00Z",
+ "track": "4",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "routeId": "de-DELFI_162290_101",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeType": 101,
+ "agencyName": "DB Fernverkehr AG",
+ "agencyUrl": "",
+ "agencyFareUrl": "",
+ "agencyId": "12681",
+ "tripId": "20261015_06:50_de-DELFI_3440679823",
+ "routeShortName": "ICE 275",
+ "routeLongName": "",
+ "tripShortName": "ICE 275",
+ "displayName": "ICE 275",
+ "cancelled": false,
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:39893110:39893116",
+ "intermediateStops": [
+ {
+ "name": "Karlsruhe Hauptbahnhof",
+ "stopId": "de-DELFI_de:08212:90:2:2",
+ "parentId": "de-DELFI_de:08212:90",
+ "importance": 0.010275337845087051,
+ "lat": 48.99373200000001,
+ "lon": 8.401070999999998,
+ "level": 1,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T05:59:00Z",
+ "departure": "2026-10-15T06:01:00Z",
+ "scheduledArrival": "2026-10-15T05:59:00Z",
+ "scheduledDeparture": "2026-10-15T06:01:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "description": "Gleis 1+2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ {
+ "name": "Offenburg Bahnhof",
+ "stopId": "de-DELFI_de:08317:14506:1:1",
+ "parentId": "de-DELFI_de:08317:14506",
+ "importance": 0.002785325516015291,
+ "lat": 48.47649,
+ "lon": 7.946129000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T06:29:00Z",
+ "departure": "2026-10-15T06:30:00Z",
+ "scheduledArrival": "2026-10-15T06:29:00Z",
+ "scheduledDeparture": "2026-10-15T06:30:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "description": "Gleis 1+2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "BUS"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ },
+ {
+ "mode": "WALK",
+ "from": {
+ "name": "Zürich HB",
+ "stopId": "de-DELFI_ch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378178000000005,
+ "lon": 8.540211999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:00:00Z",
+ "departure": "2026-10-15T09:00:00Z",
+ "scheduledArrival": "2026-10-15T09:03:00Z",
+ "scheduledDeparture": "2026-10-15T09:00:00Z",
+ "track": "10",
+ "vertexType": "TRANSIT",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "to": {
+ "name": "Zürich HB",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:3000:5:8",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378693,
+ "lon": 8.536429,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:05:00Z",
+ "scheduledArrival": "2026-10-15T09:05:00Z",
+ "scheduledTrack": "8",
+ "track": "8",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "duration": 300,
+ "startTime": "2026-10-15T09:00:00Z",
+ "endTime": "2026-10-15T09:05:00Z",
+ "scheduledStartTime": "2026-10-15T09:00:00Z",
+ "scheduledEndTime": "2026-10-15T09:05:00Z",
+ "realTime": false,
+ "scheduled": false,
+ "distance": 421,
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "steps": [],
+ "intermediateStops": []
+ },
+ {
+ "mode": "LONG_DISTANCE",
+ "from": {
+ "name": "Zürich HB",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:3000:5:8",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378693,
+ "lon": 8.536429,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T09:05:00Z",
+ "scheduledDeparture": "2026-10-15T09:05:00Z",
+ "scheduledTrack": "8",
+ "track": "8",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "to": {
+ "name": "Lugano",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:2:2",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300",
+ "importance": 0.013114487752318382,
+ "lat": 46.005657,
+ "lon": 8.946527999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T10:58:00Z",
+ "scheduledArrival": "2026-10-15T10:58:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "duration": 6780,
+ "startTime": "2026-10-15T09:05:00Z",
+ "endTime": "2026-10-15T10:58:00Z",
+ "scheduledStartTime": "2026-10-15T09:05:00Z",
+ "scheduledEndTime": "2026-10-15T10:58:00Z",
+ "realTime": false,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "Lugano",
+ "tripFrom": {
+ "name": "Zürich HB",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:3000:5:8",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:3000",
+ "importance": 0.07083380967378616,
+ "lat": 47.378693,
+ "lon": 8.536429,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T09:05:00Z",
+ "scheduledDeparture": "2026-10-15T09:05:00Z",
+ "scheduledTrack": "8",
+ "track": "8",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "tripTo": {
+ "name": "Lugano",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:2:2",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300",
+ "importance": 0.013114487752318382,
+ "lat": 46.005657,
+ "lon": 8.946527999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T10:58:00Z",
+ "scheduledArrival": "2026-10-15T10:58:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "routeId": "ch-opentransportdataswiss26_91-2-G-j26-1",
+ "routeUrl": "",
+ "directionId": "0",
+ "routeType": 102,
+ "agencyName": "Schweizerische Bundesbahnen SBB",
+ "agencyUrl": "https://sbb.ch",
+ "agencyFareUrl": "",
+ "agencyId": "11",
+ "tripId": "20261015_11:05_ch-opentransportdataswiss26_.ojp-91-2-G.1.TA.164.j26",
+ "routeShortName": "IC2",
+ "routeLongName": "",
+ "tripShortName": "869",
+ "displayName": "IC2",
+ "cancelled": false,
+ "source": "ch_opentransportdataswiss26.gtfs.zip/stop_times.txt:108396:108401",
+ "intermediateStops": [
+ {
+ "name": "Zug",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:2204:1:2",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:2204",
+ "importance": 0.014396363869309425,
+ "lat": 47.174859999999995,
+ "lon": 8.515957,
+ "level": 1,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:29:00Z",
+ "departure": "2026-10-15T09:30:00Z",
+ "scheduledArrival": "2026-10-15T09:29:00Z",
+ "scheduledDeparture": "2026-10-15T09:30:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ {
+ "name": "Arth-Goldau",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5004:2:4",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5004",
+ "importance": 0.010756864212453365,
+ "lat": 47.049150000000004,
+ "lon": 8.550308,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T09:45:00Z",
+ "departure": "2026-10-15T09:49:00Z",
+ "scheduledArrival": "2026-10-15T09:45:00Z",
+ "scheduledDeparture": "2026-10-15T09:49:00Z",
+ "scheduledTrack": "4",
+ "track": "4",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS",
+ "FUNICULAR"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ },
+ {
+ "mode": "WALK",
+ "from": {
+ "name": "Lugano",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:2:2",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300",
+ "importance": 0.013114487752318382,
+ "lat": 46.005657,
+ "lon": 8.946527999999999,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T10:58:00Z",
+ "departure": "2026-10-15T10:58:00Z",
+ "scheduledArrival": "2026-10-15T10:58:00Z",
+ "scheduledDeparture": "2026-10-15T10:58:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "vertexType": "TRANSIT",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "to": {
+ "name": "Lugano",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:1:1",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300",
+ "importance": 0.013114487752318382,
+ "lat": 46.005634,
+ "lon": 8.946646000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T11:02:00Z",
+ "scheduledArrival": "2026-10-15T11:02:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "duration": 240,
+ "startTime": "2026-10-15T10:58:00Z",
+ "endTime": "2026-10-15T11:02:00Z",
+ "scheduledStartTime": "2026-10-15T10:58:00Z",
+ "scheduledEndTime": "2026-10-15T11:02:00Z",
+ "realTime": false,
+ "scheduled": false,
+ "distance": 461,
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "steps": [],
+ "intermediateStops": []
+ },
+ {
+ "mode": "REGIONAL_RAIL",
+ "from": {
+ "name": "Lugano",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:1:1",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300",
+ "importance": 0.013114487752318382,
+ "lat": 46.005634,
+ "lon": 8.946646000000001,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T11:02:00Z",
+ "scheduledDeparture": "2026-10-15T11:02:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "to": {
+ "name": "Milano Centrale",
+ "stopId": "ch-opentransportdataswiss26_8301700",
+ "parentId": "ch-opentransportdataswiss26_Parent8301700",
+ "importance": 0.010967501439154148,
+ "lat": 45.487137,
+ "lon": 9.204821999999998,
+ "level": 2,
+ "tz": "Europe/Rome",
+ "arrival": "2026-10-15T12:17:00Z",
+ "scheduledArrival": "2026-10-15T12:17:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "duration": 4500,
+ "startTime": "2026-10-15T11:02:00Z",
+ "endTime": "2026-10-15T12:17:00Z",
+ "scheduledStartTime": "2026-10-15T11:02:00Z",
+ "scheduledEndTime": "2026-10-15T12:17:00Z",
+ "realTime": false,
+ "scheduled": true,
+ "interlineWithPreviousLeg": false,
+ "headsign": "Milano Centrale",
+ "tripFrom": {
+ "name": "Locarno",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5400:1:1",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5400",
+ "importance": 0.004943060223013163,
+ "lat": 46.17233300000001,
+ "lon": 8.802816000000002,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "departure": "2026-10-15T10:22:00Z",
+ "scheduledDeparture": "2026-10-15T10:22:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ "tripTo": {
+ "name": "Milano Centrale",
+ "stopId": "ch-opentransportdataswiss26_8301700",
+ "parentId": "ch-opentransportdataswiss26_Parent8301700",
+ "importance": 0.010967501439154148,
+ "lat": 45.487137,
+ "lon": 9.204821999999998,
+ "level": 2,
+ "tz": "Europe/Rome",
+ "arrival": "2026-10-15T12:17:00Z",
+ "scheduledArrival": "2026-10-15T12:17:00Z",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "NIGHT_RAIL",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "routeId": "ch-opentransportdataswiss26_91-80-j26-1",
+ "routeUrl": "",
+ "directionId": "0",
+ "routeType": 106,
+ "agencyName": "Schweizerische Bundesbahnen SBB",
+ "agencyUrl": "https://sbb.ch",
+ "agencyFareUrl": "",
+ "agencyId": "11",
+ "tripId": "20261015_12:22_ch-opentransportdataswiss26_.ojp-91-80.1.TA.915.j26",
+ "routeShortName": "RE80",
+ "routeLongName": "",
+ "tripShortName": "25521",
+ "displayName": "RE80",
+ "cancelled": false,
+ "source": "ch_opentransportdataswiss26.gtfs.zip/stop_times.txt:925419:925434",
+ "intermediateStops": [
+ {
+ "name": "Paradiso",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5301:1:1",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5301",
+ "importance": 0.005389102268964052,
+ "lat": 45.98940699999999,
+ "lon": 8.946888,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T11:05:00Z",
+ "departure": "2026-10-15T11:05:00Z",
+ "scheduledArrival": "2026-10-15T11:05:00Z",
+ "scheduledDeparture": "2026-10-15T11:05:00Z",
+ "scheduledTrack": "1",
+ "track": "1",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ },
+ {
+ "name": "Mendrisio",
+ "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5305:2:2",
+ "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5305",
+ "importance": 0.0074639697559177876,
+ "lat": 45.869205,
+ "lon": 8.978509,
+ "level": 0,
+ "tz": "Europe/Zurich",
+ "arrival": "2026-10-15T11:18:00Z",
+ "departure": "2026-10-15T11:18:00Z",
+ "scheduledArrival": "2026-10-15T11:18:00Z",
+ "scheduledDeparture": "2026-10-15T11:18:00Z",
+ "scheduledTrack": "2",
+ "track": "2",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "LONG_DISTANCE",
+ "REGIONAL_RAIL",
+ "SUBURBAN"
+ ]
+ }
+ ],
+ "legGeometry": {
+ "points": "u~sqF}dceAu@"
+ },
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "alternatives": [],
+ "steps": []
+ }
+ ]
+ }
+ ],
+ "previousPageCursor": "EARLIER|1792038420",
+ "nextPageCursor": "LATER|1792045500"
+}
\ No newline at end of file
diff --git a/website/test/fixtures/transitous/stoptimes.json b/website/test/fixtures/transitous/stoptimes.json
new file mode 100644
index 000000000..474b7d95b
--- /dev/null
+++ b/website/test/fixtures/transitous/stoptimes.json
@@ -0,0 +1,830 @@
+{
+ "stopTimes": [
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:1:D",
+ "importance": 0.003857618197798729,
+ "lat": 49.404906999999994,
+ "lon": 8.675713,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:26:00Z",
+ "departure": "2026-10-15T04:27:00Z",
+ "scheduledArrival": "2026-10-15T04:26:00Z",
+ "scheduledDeparture": "2026-10-15T04:27:00Z",
+ "scheduledTrack": "D",
+ "track": "D",
+ "description": "Tram/Bus",
+ "vertexType": "TRANSIT",
+ "pickupType": "NOT_ALLOWED",
+ "dropoffType": "NOT_ALLOWED",
+ "cancelled": true,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "TRAM",
+ "realTime": true,
+ "headsign": "Edingen",
+ "tripFrom": {
+ "name": "Käfertal, Bahnhof (RNV)",
+ "stopId": "de-DELFI_de:08222:5547:1:3",
+ "importance": 0.00008580861322116107,
+ "lat": 49.510146999999996,
+ "lon": 8.518570999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "G1",
+ "track": "G4",
+ "description": "Tram",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Viernheim, Bahnhof",
+ "stopId": "de-DELFI_de:06431:3851:2:TrRiO",
+ "importance": 0.00004391103720990941,
+ "lat": 49.535397,
+ "lon": 8.582630000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "3",
+ "track": "3",
+ "description": "Tram",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "agencyId": "7911",
+ "agencyName": "RNV HD-MA-Weinheim (Strab)",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:vrn:02005:_0",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "00965e",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_05:41_de-DELFI_3436853287",
+ "routeType": 0,
+ "routeShortName": "RNV 5",
+ "routeLongName": "",
+ "tripShortName": "5200093",
+ "displayName": "RNV 5",
+ "pickupDropoffType": "NOT_ALLOWED",
+ "cancelled": true,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:5163314:5163355"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:1:P",
+ "importance": 0.003857618197798729,
+ "lat": 49.404163,
+ "lon": 8.677789,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:26:00Z",
+ "departure": "2026-10-15T04:27:00Z",
+ "scheduledArrival": "2026-10-15T04:26:00Z",
+ "scheduledDeparture": "2026-10-15T04:27:00Z",
+ "scheduledTrack": "P",
+ "track": "P",
+ "description": "Bus Ost",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "BUS",
+ "realTime": true,
+ "headsign": "Heidelberg, Universitätsplatz",
+ "tripFrom": {
+ "name": "Emmertsgrund, Endstelle",
+ "stopId": "de-DELFI_de:08221:1127:0:11",
+ "importance": 0.00002280418630107306,
+ "lat": 49.358936,
+ "lon": 8.704378999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Heidelberg, Universitätsplatz",
+ "stopId": "de-DELFI_de:08221:1198:0:11",
+ "importance": 0.00001679499473539181,
+ "lat": 49.4111,
+ "lon": 8.705789000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "BUS"
+ ]
+ },
+ "agencyId": "7915",
+ "agencyName": "RNV Heidelberg (Bus)",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:vrn:02033:_3",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "e3000b",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_05:48_de-DELFI_3429190890",
+ "routeType": 3,
+ "routeShortName": "RNV 33",
+ "routeLongName": "",
+ "tripShortName": "2200057",
+ "displayName": "RNV 33",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:21177333:21177365"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:1:P",
+ "importance": 0.003857618197798729,
+ "lat": 49.404163,
+ "lon": 8.677789,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:28:00Z",
+ "departure": "2026-10-15T04:28:00Z",
+ "scheduledArrival": "2026-10-15T04:30:00Z",
+ "scheduledDeparture": "2026-10-15T04:30:00Z",
+ "scheduledTrack": "P",
+ "track": "P",
+ "description": "Bus Ost",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "BUS",
+ "realTime": true,
+ "headsign": "Neuenheim, Schwimmbad",
+ "tripFrom": {
+ "name": "Emmertsgrund, Mombertplatz",
+ "stopId": "de-DELFI_de:08221:1130:0:A",
+ "importance": 0.00003534605275490321,
+ "lat": 49.369510000000005,
+ "lon": 8.704046,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "A",
+ "track": "A",
+ "description": "Bus",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Neuenheim, Schwimmbad",
+ "stopId": "de-DELFI_de:08221:1240:0:12",
+ "importance": 0.00001845390215748921,
+ "lat": 49.420383,
+ "lon": 8.659337,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "B",
+ "track": "B",
+ "description": "Neuenheim, Schwimmbad",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "BUS"
+ ]
+ },
+ "agencyId": "7915",
+ "agencyName": "RNV Heidelberg (Bus)",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:vrn:02028:_3",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "b09fcd",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_06:00_de-DELFI_3436862110",
+ "routeType": 3,
+ "routeShortName": "RNV 28",
+ "routeLongName": "",
+ "tripShortName": "4200024",
+ "displayName": "RNV 28",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:28457739:28457763"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:2:Ri NW",
+ "importance": 0.003857618197798729,
+ "lat": 49.404635999999996,
+ "lon": 8.676513,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:28:00Z",
+ "departure": "2026-10-15T04:29:00Z",
+ "scheduledArrival": "2026-10-15T04:27:00Z",
+ "scheduledDeparture": "2026-10-15T04:28:00Z",
+ "scheduledTrack": "B",
+ "track": "B",
+ "description": "Tram/Bus",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "TRAM",
+ "realTime": true,
+ "headsign": "Heidelberg Bismarckplatz",
+ "tripFrom": {
+ "name": "Weinheim, Alter OEG-Bahnhof",
+ "stopId": "de-DELFI_de:08226:4146:1:TrRiN",
+ "importance": 0.00001689577584329527,
+ "lat": 49.549206,
+ "lon": 8.665032000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "TrRiN",
+ "track": "TrRiN",
+ "description": "TrRiN Bahnhof DB",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "RIDE_SHARING",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "tripTo": {
+ "name": "Weinheim, Alter OEG-Bahnhof",
+ "stopId": "de-DELFI_de:08226:4146:1:TrRiN",
+ "importance": 0.00001689577584329527,
+ "lat": 49.549206,
+ "lon": 8.665032000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "TrRiN",
+ "track": "TrRiN",
+ "description": "TrRiN Bahnhof DB",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "RIDE_SHARING",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "agencyId": "7911",
+ "agencyName": "RNV HD-MA-Weinheim (Strab)",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:vrn:02005:_0",
+ "routeUrl": "",
+ "directionId": "0",
+ "routeColor": "00965e",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_04:55_de-DELFI_3436853968",
+ "routeType": 0,
+ "routeShortName": "RNV 5",
+ "routeLongName": "",
+ "tripShortName": "5200068",
+ "displayName": "RNV 5",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:5190985:5191057"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:6:BusO1",
+ "importance": 0.003857618197798729,
+ "lat": 49.40441,
+ "lon": 8.676772999999999,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "departure": "2026-10-15T04:29:00Z",
+ "scheduledDeparture": "2026-10-15T04:29:00Z",
+ "scheduledTrack": "K",
+ "track": "K",
+ "description": "Bus W.-Brandt-Pl.",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "BUS",
+ "realTime": true,
+ "headsign": "Schatthausen, Kirche",
+ "tripFrom": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:6:BusO1",
+ "importance": 0.003857618197798729,
+ "lat": 49.40441,
+ "lon": 8.676772999999999,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "K",
+ "track": "K",
+ "description": "Bus W.-Brandt-Pl.",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "tripTo": {
+ "name": "Schatthausen, Kirche",
+ "stopId": "de-DELFI_de:08226:4247:0:RiN",
+ "importance": 0.000016631132893962786,
+ "lat": 49.31796599999999,
+ "lon": 8.757945,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "RiO",
+ "track": "RiO",
+ "description": "Schatthausen, Kirche",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "BUS",
+ "ODM"
+ ]
+ },
+ "agencyId": "7927",
+ "agencyName": "Busverkehr-Rhein-Neckar",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:vrn:02757:_3",
+ "routeUrl": "",
+ "directionId": "0",
+ "routeColor": "6c91a7",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_06:29_de-DELFI_3429202375",
+ "routeType": 3,
+ "routeShortName": "757",
+ "routeLongName": "",
+ "tripShortName": "3200101",
+ "displayName": "757",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:33185171:33185190"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:8:10",
+ "parentId": "de-DELFI_de:08221:1160_G",
+ "importance": 0.003857618197798729,
+ "lat": 49.403503,
+ "lon": 8.674374999999998,
+ "level": -1,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:31:00Z",
+ "departure": "2026-10-15T04:32:00Z",
+ "scheduledArrival": "2026-10-15T04:30:00Z",
+ "scheduledDeparture": "2026-10-15T04:31:00Z",
+ "scheduledTrack": "10",
+ "track": "10",
+ "description": "Gleis 9, Gleis 10",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "METRO",
+ "realTime": true,
+ "headsign": "Karlsruhe Hauptbahnhof",
+ "tripFrom": {
+ "name": "Kaiserslautern, Hbf",
+ "stopId": "de-DELFI_de:07312:9035:4:5",
+ "parentId": "de-DELFI_de:07312:9035",
+ "importance": 0.0021683480590581894,
+ "lat": 49.43573800000001,
+ "lon": 7.768622000000001,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "5",
+ "track": "5",
+ "description": "Gl4/5",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "COACH",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "tripTo": {
+ "name": "Karlsruhe Hauptbahnhof",
+ "stopId": "de-DELFI_de:08212:90:4:6",
+ "parentId": "de-DELFI_de:08212:90",
+ "importance": 0.010275337845087051,
+ "lat": 48.993366,
+ "lon": 8.401277999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "6",
+ "track": "6",
+ "description": "Gleis 5+6",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "agencyId": "10449",
+ "agencyName": "DB Regio AG Mitte Region Südwest",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:rlp:s3:_109",
+ "routeUrl": "",
+ "directionId": "0",
+ "routeColor": "fddd04",
+ "routeTextColor": "231f20",
+ "tripId": "20261015_04:55_de-DELFI_3440478632",
+ "routeType": 109,
+ "routeShortName": "S3",
+ "routeLongName": "",
+ "tripShortName": "038315",
+ "displayName": "S3",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:41039226:41039260"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:2:RiO",
+ "importance": 0.003857618197798729,
+ "lat": 49.40467999999999,
+ "lon": 8.676531,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:31:00Z",
+ "departure": "2026-10-15T04:32:00Z",
+ "scheduledArrival": "2026-10-15T04:31:00Z",
+ "scheduledDeparture": "2026-10-15T04:32:00Z",
+ "scheduledTrack": "C",
+ "track": "C",
+ "description": "Tram/Bus",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "TRAM",
+ "realTime": true,
+ "headsign": "Rohrbach, Süd",
+ "tripFrom": {
+ "name": "Handschuh., Heiligenbergschule",
+ "stopId": "de-DELFI_de:08221:1333:1:Tram",
+ "importance": 0.0000730760584701784,
+ "lat": 49.423320000000004,
+ "lon": 8.678964999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "A",
+ "track": "A",
+ "description": "Ri Technologie",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "TRAM"
+ ]
+ },
+ "tripTo": {
+ "name": "Rohrbach, Süd",
+ "stopId": "de-DELFI_de:08221:1278:1:TrRiS",
+ "importance": 0.00008212682587327436,
+ "lat": 49.365837,
+ "lon": 8.688694,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "A",
+ "track": "A",
+ "description": "Tram",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "agencyId": "7910",
+ "agencyName": "RNV Heidelberg (Strab)",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:vrn:02024:_0",
+ "routeUrl": "",
+ "directionId": "0",
+ "routeColor": "8c1d75",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_06:23_de-DELFI_3429231255",
+ "routeType": 0,
+ "routeShortName": "RNV 24",
+ "routeLongName": "",
+ "tripShortName": "1200102",
+ "displayName": "RNV 24",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:4428863:4428881"
+ },
+ {
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160:4:5",
+ "parentId": "de-DELFI_de:08221:1160_G",
+ "importance": 0.003857618197798729,
+ "lat": 49.403819999999996,
+ "lon": 8.674716,
+ "level": -1,
+ "tz": "Europe/Berlin",
+ "arrival": "2026-10-15T04:31:00Z",
+ "departure": "2026-10-15T04:33:00Z",
+ "scheduledArrival": "2026-10-15T04:28:00Z",
+ "scheduledDeparture": "2026-10-15T04:31:00Z",
+ "scheduledTrack": "5",
+ "track": "5",
+ "description": "Gleis 4, Gleis 5",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "mode": "METRO",
+ "realTime": true,
+ "headsign": "Kaiserslautern, Hbf",
+ "tripFrom": {
+ "name": "Karlsruhe Hauptbahnhof",
+ "stopId": "de-DELFI_de:08212:90:4:6",
+ "parentId": "de-DELFI_de:08212:90",
+ "importance": 0.010275337845087051,
+ "lat": 48.993366,
+ "lon": 8.401277999999998,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "6",
+ "track": "6",
+ "description": "Gleis 5+6",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "NIGHT_RAIL",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS"
+ ]
+ },
+ "tripTo": {
+ "name": "Kaiserslautern, Hbf",
+ "stopId": "de-DELFI_de:07312:9035:3:3",
+ "parentId": "de-DELFI_de:07312:9035",
+ "importance": 0.0021683480590581894,
+ "lat": 49.435905000000005,
+ "lon": 7.7685319999999995,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "scheduledTrack": "3",
+ "track": "3",
+ "description": "Gl2/3",
+ "vertexType": "TRANSIT",
+ "pickupType": "NORMAL",
+ "dropoffType": "NORMAL",
+ "cancelled": false,
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "COACH",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "agencyId": "10449",
+ "agencyName": "DB Regio AG Mitte Region Südwest",
+ "agencyUrl": "",
+ "routeId": "de-DELFI_de:rlp:s2:_109",
+ "routeUrl": "",
+ "directionId": "1",
+ "routeColor": "1575c5",
+ "routeTextColor": "ffffff",
+ "tripId": "20261015_05:40_de-DELFI_3440478785",
+ "routeType": 109,
+ "routeShortName": "S2",
+ "routeLongName": "",
+ "tripShortName": "038204",
+ "displayName": "S2",
+ "pickupDropoffType": "NORMAL",
+ "cancelled": false,
+ "tripCancelled": false,
+ "bikesAllowed": false,
+ "wheelchairAccessible": "NOT_ACCESSIBLE",
+ "reservation": "NONE",
+ "source": "de_DELFI.gtfs.zip/stop_times.txt:41033856:41033888"
+ }
+ ],
+ "place": {
+ "name": "Heidelberg, Hauptbahnhof",
+ "stopId": "de-DELFI_de:08221:1160",
+ "importance": 0.003857618197798729,
+ "lat": 49.40357,
+ "lon": 8.675442000000002,
+ "level": 0,
+ "tz": "Europe/Berlin",
+ "vertexType": "TRANSIT",
+ "modes": [
+ "HIGHSPEED_RAIL",
+ "LONG_DISTANCE",
+ "COACH",
+ "RIDE_SHARING",
+ "REGIONAL_RAIL",
+ "SUBURBAN",
+ "TRAM",
+ "BUS",
+ "ODM"
+ ]
+ },
+ "previousPageCursor": "EARLIER|1792038360",
+ "nextPageCursor": "LATER|1792038840"
+}
\ No newline at end of file
diff --git a/website/test/prompts.test.ts b/website/test/prompts.test.ts
index cffd68f17..a4506f2aa 100644
--- a/website/test/prompts.test.ts
+++ b/website/test/prompts.test.ts
@@ -20,6 +20,7 @@ describe("prompt files", () => {
it("carries the tool contract's known content", () => {
// A stable marker per prompt, so a file/getter mix-up cannot pass silently.
expect(toolContractPrompt.body).toContain("bundestakt.de");
+ expect(toolContractPrompt.body).toContain("transitous.org");
expect(teenPrompt.body).toContain("teenagers");
expect(researchPrompt.body).toContain("note_findings");
});
diff --git a/website/test/transitous.test.ts b/website/test/transitous.test.ts
new file mode 100644
index 000000000..f4bf21077
--- /dev/null
+++ b/website/test/transitous.test.ts
@@ -0,0 +1,207 @@
+/**
+ * Fixtures are literal copies of the live API responses, captured 2026-10-13 against
+ * api.transitous.org — see tools/transitous.ts's header. Trimmed only where a field is
+ * unbounded per leg (intermediateStops cut to 2 entries; legGeometry/steps kept but
+ * minimal, so the selectors' dropping them is asserted against a response that really
+ * carries them). Most tests here are pure function tests against them: no fetch mocking,
+ * no cache to reset, because the module has neither.
+ */
+import { describe, it, expect, vi, afterEach } from "vitest";
+import {
+ selectStops,
+ selectBoard,
+ selectJourneys,
+ fetchGeocode,
+ fetchStoptimes,
+ fetchPlan,
+ fetchFailed,
+ type TransitousResult,
+} from "../tools/transitous";
+import geocodeFixture from "./fixtures/transitous/geocode.json";
+import stoptimesFixture from "./fixtures/transitous/stoptimes.json";
+import planFixture from "./fixtures/transitous/plan.json";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+/** Narrows the result union to its "ok" shape, asserting that status along the way. */
+function assertOk(result: TransitousResult): asserts result is { status: "ok" } & T {
+ expect(result.status).toBe("ok");
+}
+
+/** Fixture rows carry 7 STOP and 3 PLACE entries, so filter and cap are both exercised. */
+describe("selectStops", () => {
+ it("keeps only STOP entries and caps the list at six", () => {
+ const result = selectStops(geocodeFixture);
+ assertOk<{ stops: { id: string; type?: string }[] }>(result);
+
+ expect(result.stops).toHaveLength(6);
+ for (const stop of result.stops) {
+ expect(typeof stop.id).toBe("string");
+ expect(stop.type).toBeUndefined();
+ }
+ });
+
+ it("projects each stop to exactly name, id and modes", () => {
+ const result = selectStops(geocodeFixture);
+ assertOk<{ stops: Record[] }>(result);
+
+ expect(Object.keys(result.stops[0]).sort()).toEqual(["id", "modes", "name"]);
+ });
+
+ it("returns an empty list, not an error, when nothing matches", () => {
+ const result = selectStops([{ type: "PLACE", name: "Somewhere", id: "node/1", modes: [] }]);
+
+ expect(result).toEqual({ status: "ok", stops: [] });
+ });
+});
+
+describe("selectBoard", () => {
+ it("projects each departure to the board row and drops the stop name the API does not send", () => {
+ const result = selectBoard(stoptimesFixture);
+ assertOk<{ departures: Record[]; stop?: unknown }>(result);
+
+ expect(result.stop).toBeUndefined();
+ expect(result.departures).toHaveLength(8);
+ expect(Object.keys(result.departures[0]).sort()).toEqual([
+ "cancelled",
+ "delayMin",
+ "headsign",
+ "line",
+ "realTime",
+ "time",
+ "track",
+ "train",
+ ]);
+ });
+
+ it("keeps the delay's sign: late is positive, early is negative", () => {
+ const result = selectBoard(stoptimesFixture);
+ assertOk<{ departures: { delayMin: number; realTime: boolean }[] }>(result);
+
+ // The fixture board has a delayed S2 (+2), a delayed S3 (+1) and an early RNV 28 (-2).
+ expect(result.departures.some((d) => d.delayMin > 0)).toBe(true);
+ expect(result.departures.some((d) => d.delayMin < 0)).toBe(true);
+ expect(result.departures.some((d) => d.realTime)).toBe(true);
+ });
+
+ it("projects the whole board inside the byte budget of a tool result", () => {
+ const result = selectBoard(stoptimesFixture);
+
+ expect(new TextEncoder().encode(JSON.stringify(result)).length).toBeLessThanOrEqual(2048);
+ });
+});
+
+describe("selectJourneys", () => {
+ it("caps the itinerary list at three", () => {
+ const raw = { itineraries: [...planFixture.itineraries, ...planFixture.itineraries, ...planFixture.itineraries] };
+ const result = selectJourneys(raw);
+ assertOk<{ itineraries: unknown[] }>(result);
+
+ expect(result.itineraries).toHaveLength(3);
+ });
+
+ it("projects an itinerary to start, end, durationMin, transfers, realTime and legs", () => {
+ const result = selectJourneys(planFixture);
+ assertOk<{ itineraries: Record[] }>(result);
+
+ expect(result.itineraries).toHaveLength(2);
+ expect(Object.keys(result.itineraries[0]).sort()).toEqual([
+ "durationMin",
+ "end",
+ "legs",
+ "realTime",
+ "start",
+ "transfers",
+ ]);
+ expect(typeof result.itineraries[0].transfers).toBe("number");
+ expect(result.itineraries[0].realTime).toBe(true);
+ });
+
+ it("aggregates realTime over transit legs only", () => {
+ const raw = JSON.parse(JSON.stringify(planFixture));
+ for (const itinerary of raw.itineraries) {
+ for (const leg of itinerary.legs) leg.realTime = false;
+ }
+ const result = selectJourneys(raw);
+ assertOk<{ itineraries: { realTime: boolean }[] }>(result);
+
+ expect(result.itineraries.every((it) => it.realTime === false)).toBe(true);
+ });
+
+ it("renders WALK legs as a single walkMin field", () => {
+ const result = selectJourneys(planFixture);
+ assertOk<{ itineraries: { legs: Record[] }[] }>(result);
+
+ const walkLegs = result.itineraries.flatMap((it) => it.legs).filter((leg) => leg.walkMin !== undefined);
+ expect(walkLegs.length).toBeGreaterThan(0);
+ for (const leg of walkLegs) expect(Object.keys(leg)).toEqual(["walkMin"]);
+ });
+
+ it("drops the per-leg baggage the model must not pay for", () => {
+ const result = selectJourneys(planFixture);
+ assertOk<{ itineraries: { legs: Record[] }[] }>(result);
+
+ const transitLegs = result.itineraries.flatMap((it) => it.legs).filter((leg) => leg.mode !== undefined);
+ expect(transitLegs.length).toBeGreaterThan(0);
+ for (const leg of transitLegs) {
+ expect(Object.keys(leg).sort()).toEqual([
+ "agency",
+ "cancelled",
+ "from",
+ "headsign",
+ "line",
+ "mode",
+ "realTime",
+ "to",
+ "train",
+ ]);
+ expect(leg.intermediateStops).toBeUndefined();
+ expect(leg.legGeometry).toBeUndefined();
+ expect(leg.steps).toBeUndefined();
+ }
+ });
+
+ it("projects the whole plan inside the byte budget of a tool result", () => {
+ const result = selectJourneys(planFixture);
+
+ expect(new TextEncoder().encode(JSON.stringify(result)).length).toBeLessThanOrEqual(4096);
+ });
+});
+
+describe("fetchers", () => {
+ it("throw a TransitousError with the HTTP status when the API answers non-OK", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
+
+ await expect(fetchGeocode("Heidelberg Hbf")).rejects.toMatchObject({ name: "TransitousError", status: 503 });
+ await expect(fetchStoptimes("de-DELFI_de:08221:1160")).rejects.toMatchObject({ status: 503 });
+ await expect(fetchPlan({ from: "a", to: "b" })).rejects.toMatchObject({ status: 503 });
+ });
+
+ it("sends the query as URL parameters and reads the JSON body", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ stopTimes: [] }) });
+ vi.stubGlobal("fetch", mockFetch);
+
+ await fetchGeocode("Heidelberg Hbf");
+
+ const url = new URL(vi.mocked(mockFetch).mock.calls[0][0] as string);
+ expect(url).toMatchObject({ origin: "https://api.transitous.org", pathname: "/api/v1/geocode" });
+ expect(url.searchParams.get("text")).toBe("Heidelberg Hbf");
+
+ await fetchStoptimes("de-DELFI_de:08221:1160", undefined, "2026-10-16T08:00:00Z");
+
+ const boardUrl = new URL(vi.mocked(mockFetch).mock.calls[1][0] as string);
+ expect(boardUrl.pathname).toBe("/api/v1/stoptimes");
+ expect(boardUrl.searchParams.get("stopId")).toBe("de-DELFI_de:08221:1160");
+ expect(boardUrl.searchParams.get("n")).toBe("8");
+ expect(boardUrl.searchParams.get("time")).toBe("2026-10-16T08:00:00Z");
+ });
+
+ it("exports the shared failure shape for the runner's catch", () => {
+ expect(fetchFailed(new Error("Transitous answered 503"))).toEqual({
+ status: "fetch_failed",
+ reason: "Transitous answered 503",
+ });
+ });
+});
diff --git a/website/tools/transitous.ts b/website/tools/transitous.ts
new file mode 100644
index 000000000..696abb820
--- /dev/null
+++ b/website/tools/transitous.ts
@@ -0,0 +1,310 @@
+import type { X402Tool } from "../types/x402";
+
+/**
+ * Transitous (api.transitous.org, MOTIS 2 API) as tools for the chat model — stop search,
+ * departure boards and journey planning across Europe. Free, keyless, open CORS. Verified
+ * live against the API on 2026-10-13: an empty User-Agent gets a 403, but a browser's own
+ * UA is accepted — and browsers cannot set the header anyway (it is a forbidden header name
+ * in fetch, silently dropped), so no attempt is made here. The usage policy's identifiability
+ * intent is met by the Origin/Referer of the requests plus the visible citation line
+ * (`source: "transitous"` in AssistantChat's `sourceLinks`); it also asks for a heads-up in
+ * their Matrix channel before regular use of the routing endpoint, which is a merge
+ * precondition rather than a code concern.
+ *
+ * Deliberately stateless and React-free — see the fetch/select split below — so a future MCP
+ * server can import the fetchers and selectors directly and bring its own caching policy,
+ * instead of duplicating this logic or inheriting caching it doesn't want.
+ *
+ * `fetchGeocode`/`fetchStoptimes`/`fetchPlan` are dumb fetch wrappers that throw;
+ * `selectStops`/`selectBoard`/`selectJourneys` are pure functions over already-parsed JSON.
+ * The wiring lives in AssistantChat.tsx via `queryClient.fetchQuery` for caching and dedup —
+ * this module has no cache of its own.
+ */
+
+const BASE = "https://api.transitous.org/api";
+
+// Tool results are input tokens on every later hop of a metered chat endpoint, so projection
+// is mandatory, not a nicety: a raw single-itinerary /v1/plan response measures ~232 KB
+// (legGeometry, steps, intermediateStops); the projected result is ~1 KB. Coordinates are
+// dropped everywhere — maps are out of scope, and they are the single biggest token sink in
+// MOTIS responses after the per-leg baggage.
+const MAX_STOPS = 6;
+const MAX_ITINERARIES = 3;
+const MAX_LEGS = 8;
+const MAX_BOARD_ROWS = 8;
+const MAX_NAME_CHARS = 60;
+
+// --- Tool definitions (OpenAI function-calling shape) -------------------------------------
+
+export const searchStopTool: X402Tool = {
+ type: "function",
+ function: {
+ name: "search_stop",
+ description:
+ "Find stops and stations by name (Transitous, Europe-wide). Call this first when a " +
+ "journey or board request names a place, then pass a returned stop id to plan_journey " +
+ "or get_departures. If several stops match, pick with the user or take the obvious " +
+ "hub (e.g. 'Milano Centrale' over 'Milano Romolo').",
+ parameters: {
+ type: "object",
+ properties: {
+ query: {
+ type: "string",
+ description: "Stop or station name, e.g. 'Heidelberg Hbf', 'Milano Centrale'.",
+ },
+ },
+ required: ["query"],
+ },
+ },
+};
+
+export const planJourneyTool: X402Tool = {
+ type: "function",
+ function: {
+ name: "plan_journey",
+ description:
+ "Plan door-to-door journeys between two European stops (Transitous/MOTIS) by train, " +
+ "bus, coach, ferry or a mix; rail_only restricts to trains. from and to are stop ids " +
+ "from search_stop. Returns up to 3 itineraries with legs and live delays where " +
+ "operators publish them. All times are UTC ISO — convert to the user's local timezone " +
+ "before presenting. Schedules only: prices and booking are out of scope; when an " +
+ "itinerary has realTime=false, present times as scheduled and say so.",
+ parameters: {
+ type: "object",
+ properties: {
+ from: { type: "string", description: "Departure stop id from search_stop." },
+ to: { type: "string", description: "Arrival stop id from search_stop." },
+ departure: { type: "string", description: "ISO datetime; omit for 'now'." },
+ arrive_by: {
+ type: "boolean",
+ description: "Treat `departure` as the required arrival time instead of the departure time.",
+ },
+ rail_only: {
+ type: "boolean",
+ description: "Trains only, excludes buses and long-distance coaches. Default false.",
+ },
+ },
+ required: ["from", "to"],
+ },
+ },
+};
+
+export const getDeparturesTool: X402Tool = {
+ type: "function",
+ function: {
+ name: "get_departures",
+ description:
+ "Next departures from a stop with live delays (Transitous/MOTIS). Use for questions " +
+ "like 'what leaves from X in the next hour', or from a later moment ('tomorrow " +
+ "morning') by passing `time`. stop_id comes from search_stop. All times are UTC ISO " +
+ "— convert to the user's local timezone before presenting.",
+ parameters: {
+ type: "object",
+ properties: {
+ stop_id: { type: "string", description: "Stop id from search_stop." },
+ time: {
+ type: "string",
+ description: "ISO datetime the board starts from; omit for 'now'.",
+ },
+ },
+ required: ["stop_id"],
+ },
+ },
+};
+
+// --- Wire shapes (only the fields we read) -------------------------------------------------
+
+interface RawGeocodeRow {
+ type?: string;
+ name?: string;
+ id?: string;
+ modes?: string[];
+}
+
+interface RawPlace {
+ name?: string;
+ arrival?: string;
+ departure?: string;
+ scheduledArrival?: string;
+ scheduledDeparture?: string;
+ track?: string;
+ scheduledTrack?: string;
+}
+
+interface RawStoptime {
+ place?: RawPlace;
+ routeShortName?: string;
+ displayName?: string;
+ tripShortName?: string;
+ headsign?: string;
+ cancelled?: boolean;
+ realTime?: boolean;
+}
+
+interface RawStoptimesResponse {
+ stopTimes?: RawStoptime[];
+}
+
+interface RawLeg {
+ mode?: string;
+ duration?: number;
+ routeShortName?: string;
+ displayName?: string;
+ tripShortName?: string;
+ headsign?: string;
+ agencyName?: string;
+ from?: RawPlace;
+ to?: RawPlace;
+ cancelled?: boolean;
+ realTime?: boolean;
+}
+
+interface RawItinerary {
+ startTime?: string;
+ endTime?: string;
+ duration?: number;
+ transfers?: number;
+ legs?: RawLeg[];
+}
+
+interface RawPlanResponse {
+ itineraries?: RawItinerary[];
+}
+
+// --- Result contract ------------------------------------------------------------------------
+
+export type TransitousResult =
+ | { status: "ok"; [key: string]: unknown }
+ | { status: "bad_query"; reason: string }
+ | { status: "fetch_failed"; reason: string };
+
+/** Turns a fetch-time error (thrown by the fetchers) into a result. */
+export { fetchFailed } from "./failure";
+
+// --- Fetchers: plain fetch, no cache, throw on failure ---------------------------------------
+
+export class TransitousError extends Error {
+ constructor(message: string, readonly status: number | null = null) {
+ super(message);
+ this.name = "TransitousError";
+ }
+}
+
+async function get(path: string, params: Record): Promise {
+ const q = new URLSearchParams();
+ for (const [k, v] of Object.entries(params)) if (v) q.set(k, v);
+ const res = await fetch(`${BASE}${path}?${q}`, { headers: { accept: "application/json" } });
+ if (!res.ok) throw new TransitousError(`Transitous answered ${res.status}`, res.status);
+ return res.json() as Promise;
+}
+
+export const fetchGeocode = (query: string): Promise =>
+ get("/v1/geocode", { text: query });
+
+export const fetchStoptimes = (stopId: string, n = MAX_BOARD_ROWS, time?: string): Promise =>
+ get("/v1/stoptimes", { stopId, n: String(n), time });
+
+export const fetchPlan = (args: {
+ from: string;
+ to: string;
+ time?: string;
+ arriveBy?: boolean;
+ transitModes?: string;
+}): Promise =>
+ get("/v1/plan", {
+ fromPlace: args.from,
+ toPlace: args.to,
+ numItineraries: String(MAX_ITINERARIES),
+ time: args.time,
+ arriveBy: args.arriveBy ? "true" : undefined,
+ transitModes: args.transitModes,
+ });
+
+// --- Selectors: pure, synchronous, operate on already-parsed JSON ---------------------------
+
+const minutesBetween = (a?: string, b?: string) =>
+ a && b ? Math.round((Date.parse(a) - Date.parse(b)) / 60000) : 0;
+
+const clip = (s: unknown, n = MAX_NAME_CHARS): string | null => {
+ if (typeof s !== "string" || !s) return null;
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
+};
+
+/** One board/leg endpoint: name, the live time, the delay in minutes and the track. */
+const place = (p: RawPlace = {}) => {
+ const time = p.departure ?? p.arrival;
+ const scheduled = p.scheduledDeparture ?? p.scheduledArrival;
+ return {
+ name: clip(p.name),
+ time: time ?? null,
+ // The one number users act on. Positive = late; the sign must survive projection.
+ delayMin: minutesBetween(time, scheduled),
+ track: clip(p.track ?? p.scheduledTrack, 8),
+ };
+};
+
+/** [{name, id, modes}] — ids survive because the next call needs them. */
+export function selectStops(raw: unknown): TransitousResult {
+ const rows = (Array.isArray(raw) ? raw : []) as RawGeocodeRow[];
+ return {
+ status: "ok",
+ stops: rows
+ .filter((r) => r?.type === "STOP")
+ .slice(0, MAX_STOPS)
+ .map((r) => ({ name: clip(r.name), id: r.id, modes: r.modes ?? [] })),
+ };
+}
+
+/** Board rows only, no stop name: /v1/stoptimes has no top-level place field, and the model
+ * already knows the stop — it picked the id via search_stop. */
+export function selectBoard(raw: unknown): TransitousResult {
+ const board = (raw as RawStoptimesResponse | null) ?? {};
+ return {
+ status: "ok",
+ departures: (board.stopTimes ?? []).slice(0, MAX_BOARD_ROWS).map((s) => ({
+ line: clip(s.routeShortName ?? s.displayName, 16),
+ train: clip(s.tripShortName, 12),
+ headsign: clip(s.headsign),
+ time: s.place?.departure ?? null,
+ delayMin: minutesBetween(s.place?.departure, s.place?.scheduledDeparture),
+ track: clip(s.place?.track ?? s.place?.scheduledTrack, 8),
+ cancelled: !!s.cancelled,
+ // false = the operator publishes no live feed for this trip; the schedule is all we have.
+ realTime: !!s.realTime,
+ })),
+ };
+}
+
+export function selectJourneys(raw: unknown): TransitousResult {
+ const plan = (raw as RawPlanResponse | null) ?? {};
+ return {
+ status: "ok",
+ itineraries: (plan.itineraries ?? []).slice(0, MAX_ITINERARIES).map((it) => {
+ const legs = (it.legs ?? []).slice(0, MAX_LEGS).map((l) =>
+ l.mode === "WALK"
+ ? { walkMin: Math.round((l.duration ?? 0) / 60) }
+ : {
+ mode: l.mode ?? null,
+ line: clip(l.routeShortName ?? l.displayName, 16),
+ train: clip(l.tripShortName, 12),
+ headsign: clip(l.headsign),
+ agency: clip(l.agencyName, 30),
+ from: place(l.from),
+ to: place(l.to),
+ cancelled: !!l.cancelled,
+ realTime: !!l.realTime,
+ },
+ );
+ const rides = (it.legs ?? []).filter((l) => l.mode !== "WALK");
+ return {
+ start: it.startTime ?? null,
+ end: it.endTime ?? null,
+ durationMin: Math.round((it.duration ?? 0) / 60),
+ transfers: it.transfers ?? Math.max(0, rides.length - 1),
+ // No live feed anywhere on the route -> the model must say "scheduled times".
+ realTime: rides.some((l) => l.realTime),
+ legs,
+ };
+ }),
+ };
+}
From 51c2ed617f010a0740189c961ce3efbbecdd823a Mon Sep 17 00:00:00 2001
From: fretchen
Date: Thu, 15 Oct 2026 07:46:38 +0200
Subject: [PATCH 2/5] prettier
---
website/components/AssistantChat.tsx | 5 +-
website/locales/de.ts | 3 +-
website/locales/en.ts | 3 +-
website/test/AssistantChat.test.tsx | 4 +-
website/test/fixtures/transitous/geocode.json | 40 +--
website/test/fixtures/transitous/plan.json | 307 +++---------------
.../test/fixtures/transitous/stoptimes.json | 75 +----
website/tools/transitous.ts | 11 +-
8 files changed, 74 insertions(+), 374 deletions(-)
diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx
index be7f99662..44b2ac115 100644
--- a/website/components/AssistantChat.tsx
+++ b/website/components/AssistantChat.tsx
@@ -877,7 +877,10 @@ export function AssistantChat() {
*/
const argString = (v: unknown): string => (typeof v === "string" ? v : "");
- async function loadTransitous(kind: "geocode" | "stoptimes" | "plan", args: Record): Promise {
+ async function loadTransitous(
+ kind: "geocode" | "stoptimes" | "plan",
+ args: Record,
+ ): Promise {
// An empty required argument is an answer the model can act on, not a malfunction: fetching
// anyway would 4xx at the API, `fetch_failed` would withdraw the tool for the rest of the
// turn, and with it the one thing that fixes the mistake — a corrected query, a real stop
diff --git a/website/locales/de.ts b/website/locales/de.ts
index da1d61866..04357b6a9 100644
--- a/website/locales/de.ts
+++ b/website/locales/de.ts
@@ -117,7 +117,8 @@ export default {
// in ToolSelector kommt vom ersten Eintrag der Gruppe (siehe TOOL_REGISTRY), und das
// ist dieser — sie beschreibt deshalb die ganze Fähigkeit und nennt die Verkehrsmittel,
// weil das Zeilenlabel ("Fahrplan") bewusst gattungsneutral bleibt.
- toolTransitSearchDesc: "Züge, Busse und Fähren in ganz Europa – Verbindungen und Abfahrten, mit Echtzeit, wo Betreiber sie liefern.",
+ toolTransitSearchDesc:
+ "Züge, Busse und Fähren in ganz Europa – Verbindungen und Abfahrten, mit Echtzeit, wo Betreiber sie liefern.",
toolTransitPlanDesc: "Plant eine Verbindung von Tür zu Tür – Züge, Busse, Fähren.",
toolTransitDeparturesDesc: "Die nächsten Abfahrten einer Haltestelle.",
// Zeilentitel für die zusammengefasste Zeile in ToolSelector (get_sitzungen +
diff --git a/website/locales/en.ts b/website/locales/en.ts
index a5303751a..a55a24d34 100644
--- a/website/locales/en.ts
+++ b/website/locales/en.ts
@@ -114,7 +114,8 @@ export default {
// The one actually shown: the grouped timetable row's description comes from the first
// entry in that group (see TOOL_REGISTRY), so this describes the whole capability and
// names the modes — the row label stays deliberately generic.
- toolTransitSearchDesc: "Trains, buses and ferries across Europe — journeys and departures, with live times where operators publish them.",
+ toolTransitSearchDesc:
+ "Trains, buses and ferries across Europe — journeys and departures, with live times where operators publish them.",
toolTransitPlanDesc: "Plans a door-to-door journey — trains, buses, ferries.",
toolTransitDeparturesDesc: "The next departures from a stop.",
capBundestag: "Bundestag",
diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx
index 5eb1beefc..205ba1839 100644
--- a/website/test/AssistantChat.test.tsx
+++ b/website/test/AssistantChat.test.tsx
@@ -906,9 +906,7 @@ describe("AssistantChat", () => {
// (TOOL_REGISTRY's `group`, whose locale value is the row label), so one click
// switches off all three wire names at once.
fireEvent.click(screen.getAllByLabelText("Train & bus")[0]);
- expect(window.localStorage.getItem("x402-chat-disabled-tools")).toBe(
- "search_stop,plan_journey,get_departures",
- );
+ expect(window.localStorage.getItem("x402-chat-disabled-tools")).toBe("search_stop,plan_journey,get_departures");
sendUserMessage("Hi");
await waitFor(() => expect(mockSendMessage).toHaveBeenCalledOnce());
diff --git a/website/test/fixtures/transitous/geocode.json b/website/test/fixtures/transitous/geocode.json
index c9b4bb187..5399171c2 100644
--- a/website/test/fixtures/transitous/geocode.json
+++ b/website/test/fixtures/transitous/geocode.json
@@ -155,10 +155,7 @@
}
],
"score": -26.631120681762695,
- "modes": [
- "TRAM",
- "BUS"
- ],
+ "modes": ["TRAM", "BUS"],
"importance": 0.000045373457396635786
},
{
@@ -306,16 +303,7 @@
}
],
"score": -18.81806755065918,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "SUBWAY",
- "TRAM",
- "BUS"
- ],
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "SUBWAY", "TRAM", "BUS"],
"importance": 0.021757638081908226
},
{
@@ -359,16 +347,7 @@
}
],
"score": -18.813016891479492,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "COACH",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "SUBWAY",
- "BUS"
- ],
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "COACH", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "SUBWAY", "BUS"],
"importance": 0.021419377997517586
},
{
@@ -539,16 +518,7 @@
}
],
"score": -18.104284286499023,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "COACH",
- "NIGHT_RAIL",
- "RIDE_SHARING",
- "REGIONAL_RAIL",
- "TRAM",
- "BUS"
- ],
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "COACH", "NIGHT_RAIL", "RIDE_SHARING", "REGIONAL_RAIL", "TRAM", "BUS"],
"importance": 0.005886611994355917
}
-]
\ No newline at end of file
+]
diff --git a/website/test/fixtures/transitous/plan.json b/website/test/fixtures/transitous/plan.json
index d109ec472..a66f0ca97 100644
--- a/website/test/fixtures/transitous/plan.json
+++ b/website/test/fixtures/transitous/plan.json
@@ -30,15 +30,7 @@
"level": 2,
"tz": "Europe/Rome",
"vertexType": "TRANSIT",
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"direct": [],
"itineraries": [
@@ -167,11 +159,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS"
- ]
+ "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS"]
},
"routeId": "de-DELFI_de:rlp:s3:_109",
"routeUrl": "",
@@ -211,12 +199,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS",
- "ODM"
- ]
+ "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"]
},
{
"name": "MA-Friedrichsfeld, Südbahnhof",
@@ -238,11 +221,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "SUBURBAN",
- "BUS",
- "ODM"
- ]
+ "modes": ["SUBURBAN", "BUS", "ODM"]
}
],
"legGeometry": {
@@ -374,13 +353,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"duration": 12180,
"startTime": "2026-10-15T05:37:00Z",
@@ -406,15 +379,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"tripTo": {
"name": "Basel SBB",
@@ -431,15 +396,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"routeId": "de-DELFI_162290_101",
"routeUrl": "",
@@ -509,13 +466,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "BUS"]
}
],
"legGeometry": {
@@ -544,13 +495,7 @@
"track": "10",
"vertexType": "TRANSIT",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"to": {
"name": "ZUERICH HB",
@@ -564,13 +509,7 @@
"arrival": "2026-10-15T09:05:00Z",
"scheduledArrival": "2026-10-15T09:05:00Z",
"vertexType": "TRANSIT",
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"duration": 300,
"startTime": "2026-10-15T09:00:00Z",
@@ -603,13 +542,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"to": {
"name": "MILANO CENTRALE",
@@ -626,15 +559,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"duration": 11820,
"startTime": "2026-10-15T09:33:00Z",
@@ -660,13 +585,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"tripTo": {
"name": "MILANO CENTRALE",
@@ -683,15 +602,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"routeId": "it-trenitalia_60083_7cf9622d2ca9dab7",
"routeUrl": "",
@@ -728,10 +639,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "REGIONAL_RAIL"
- ]
+ "modes": ["LONG_DISTANCE", "REGIONAL_RAIL"]
},
{
"name": "ARTH-GOLDAU",
@@ -750,14 +658,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS",
- "FUNICULAR"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "BUS", "FUNICULAR"]
}
],
"legGeometry": {
@@ -896,11 +797,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS"
- ]
+ "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS"]
},
"routeId": "de-DELFI_de:rlp:s3:_109",
"routeUrl": "",
@@ -940,12 +837,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS",
- "ODM"
- ]
+ "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"]
},
{
"name": "MA-Friedrichsfeld, Südbahnhof",
@@ -967,11 +859,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "SUBURBAN",
- "BUS",
- "ODM"
- ]
+ "modes": ["SUBURBAN", "BUS", "ODM"]
}
],
"legGeometry": {
@@ -1103,13 +991,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"duration": 12180,
"startTime": "2026-10-15T05:37:00Z",
@@ -1135,15 +1017,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"tripTo": {
"name": "Basel SBB",
@@ -1160,15 +1034,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"routeId": "de-DELFI_162290_101",
"routeUrl": "",
@@ -1238,13 +1104,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "BUS"]
}
],
"legGeometry": {
@@ -1273,13 +1133,7 @@
"track": "10",
"vertexType": "TRANSIT",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"to": {
"name": "Zürich HB",
@@ -1295,13 +1149,7 @@
"scheduledTrack": "8",
"track": "8",
"vertexType": "TRANSIT",
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"duration": 300,
"startTime": "2026-10-15T09:00:00Z",
@@ -1336,13 +1184,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"to": {
"name": "Lugano",
@@ -1361,12 +1203,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"duration": 6780,
"startTime": "2026-10-15T09:05:00Z",
@@ -1394,13 +1231,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"tripTo": {
"name": "Lugano",
@@ -1419,12 +1250,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"routeId": "ch-opentransportdataswiss26_91-2-G-j26-1",
"routeUrl": "",
@@ -1461,11 +1287,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "REGIONAL_RAIL", "SUBURBAN"]
},
{
"name": "Arth-Goldau",
@@ -1486,14 +1308,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS",
- "FUNICULAR"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "BUS", "FUNICULAR"]
}
],
"legGeometry": {
@@ -1524,12 +1339,7 @@
"track": "2",
"vertexType": "TRANSIT",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"to": {
"name": "Lugano",
@@ -1545,12 +1355,7 @@
"scheduledTrack": "1",
"track": "1",
"vertexType": "TRANSIT",
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"duration": 240,
"startTime": "2026-10-15T10:58:00Z",
@@ -1585,12 +1390,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"]
},
"to": {
"name": "Milano Centrale",
@@ -1607,15 +1407,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"duration": 4500,
"startTime": "2026-10-15T11:02:00Z",
@@ -1643,11 +1435,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "REGIONAL_RAIL", "SUBURBAN"]
},
"tripTo": {
"name": "Milano Centrale",
@@ -1664,15 +1452,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "LONG_DISTANCE",
- "NIGHT_RAIL",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "TRAM",
- "BUS"
- ]
+ "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"]
},
"routeId": "ch-opentransportdataswiss26_91-80-j26-1",
"routeUrl": "",
@@ -1709,10 +1489,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["REGIONAL_RAIL", "SUBURBAN"]
},
{
"name": "Mendrisio",
@@ -1733,11 +1510,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "LONG_DISTANCE",
- "REGIONAL_RAIL",
- "SUBURBAN"
- ]
+ "modes": ["LONG_DISTANCE", "REGIONAL_RAIL", "SUBURBAN"]
}
],
"legGeometry": {
@@ -1754,4 +1527,4 @@
],
"previousPageCursor": "EARLIER|1792038420",
"nextPageCursor": "LATER|1792045500"
-}
\ No newline at end of file
+}
diff --git a/website/test/fixtures/transitous/stoptimes.json b/website/test/fixtures/transitous/stoptimes.json
index 474b7d95b..36cfe919b 100644
--- a/website/test/fixtures/transitous/stoptimes.json
+++ b/website/test/fixtures/transitous/stoptimes.json
@@ -50,10 +50,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "TRAM",
- "BUS"
- ]
+ "modes": ["TRAM", "BUS"]
},
"tripTo": {
"name": "Viernheim, Bahnhof",
@@ -70,11 +67,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "TRAM",
- "BUS",
- "ODM"
- ]
+ "modes": ["TRAM", "BUS", "ODM"]
},
"agencyId": "7911",
"agencyName": "RNV HD-MA-Weinheim (Strab)",
@@ -145,9 +138,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "BUS"
- ]
+ "modes": ["BUS"]
},
"tripTo": {
"name": "Heidelberg, Universitätsplatz",
@@ -161,9 +152,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "BUS"
- ]
+ "modes": ["BUS"]
},
"agencyId": "7915",
"agencyName": "RNV Heidelberg (Bus)",
@@ -237,9 +226,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "BUS"
- ]
+ "modes": ["BUS"]
},
"tripTo": {
"name": "Neuenheim, Schwimmbad",
@@ -256,9 +243,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "BUS"
- ]
+ "modes": ["BUS"]
},
"agencyId": "7915",
"agencyName": "RNV Heidelberg (Bus)",
@@ -332,12 +317,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "RIDE_SHARING",
- "TRAM",
- "BUS",
- "ODM"
- ]
+ "modes": ["RIDE_SHARING", "TRAM", "BUS", "ODM"]
},
"tripTo": {
"name": "Weinheim, Alter OEG-Bahnhof",
@@ -354,12 +334,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "RIDE_SHARING",
- "TRAM",
- "BUS",
- "ODM"
- ]
+ "modes": ["RIDE_SHARING", "TRAM", "BUS", "ODM"]
},
"agencyId": "7911",
"agencyName": "RNV HD-MA-Weinheim (Strab)",
@@ -458,10 +433,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "BUS",
- "ODM"
- ]
+ "modes": ["BUS", "ODM"]
},
"agencyId": "7927",
"agencyName": "Busverkehr-Rhein-Neckar",
@@ -537,14 +509,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "COACH",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS",
- "ODM"
- ]
+ "modes": ["HIGHSPEED_RAIL", "COACH", "REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"]
},
"tripTo": {
"name": "Karlsruhe Hauptbahnhof",
@@ -646,9 +611,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "TRAM"
- ]
+ "modes": ["TRAM"]
},
"tripTo": {
"name": "Rohrbach, Süd",
@@ -665,10 +628,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "TRAM",
- "BUS"
- ]
+ "modes": ["TRAM", "BUS"]
},
"agencyId": "7910",
"agencyName": "RNV Heidelberg (Strab)",
@@ -772,14 +732,7 @@
"pickupType": "NORMAL",
"dropoffType": "NORMAL",
"cancelled": false,
- "modes": [
- "HIGHSPEED_RAIL",
- "COACH",
- "REGIONAL_RAIL",
- "SUBURBAN",
- "BUS",
- "ODM"
- ]
+ "modes": ["HIGHSPEED_RAIL", "COACH", "REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"]
},
"agencyId": "10449",
"agencyName": "DB Regio AG Mitte Region Südwest",
@@ -827,4 +780,4 @@
},
"previousPageCursor": "EARLIER|1792038360",
"nextPageCursor": "LATER|1792038840"
-}
\ No newline at end of file
+}
diff --git a/website/tools/transitous.ts b/website/tools/transitous.ts
index 696abb820..dd75cdd22 100644
--- a/website/tools/transitous.ts
+++ b/website/tools/transitous.ts
@@ -184,7 +184,10 @@ export { fetchFailed } from "./failure";
// --- Fetchers: plain fetch, no cache, throw on failure ---------------------------------------
export class TransitousError extends Error {
- constructor(message: string, readonly status: number | null = null) {
+ constructor(
+ message: string,
+ readonly status: number | null = null,
+ ) {
super(message);
this.name = "TransitousError";
}
@@ -198,8 +201,7 @@ async function get(path: string, params: Record):
return res.json() as Promise;
}
-export const fetchGeocode = (query: string): Promise =>
- get("/v1/geocode", { text: query });
+export const fetchGeocode = (query: string): Promise => get("/v1/geocode", { text: query });
export const fetchStoptimes = (stopId: string, n = MAX_BOARD_ROWS, time?: string): Promise =>
get("/v1/stoptimes", { stopId, n: String(n), time });
@@ -222,8 +224,7 @@ export const fetchPlan = (args: {
// --- Selectors: pure, synchronous, operate on already-parsed JSON ---------------------------
-const minutesBetween = (a?: string, b?: string) =>
- a && b ? Math.round((Date.parse(a) - Date.parse(b)) / 60000) : 0;
+const minutesBetween = (a?: string, b?: string) => (a && b ? Math.round((Date.parse(a) - Date.parse(b)) / 60000) : 0);
const clip = (s: unknown, n = MAX_NAME_CHARS): string | null => {
if (typeof s !== "string" || !s) return null;
From 218d82aaf09db15afe9bc082636e1a74b2c8dac4 Mon Sep 17 00:00:00 2001
From: fretchen
Date: Fri, 16 Oct 2026 18:05:32 +0200
Subject: [PATCH 3/5] first round of changes
---
website/components/AssistantChat.tsx | 22 +++-
website/hooks/useX402Chat.ts | 55 ++++++++--
website/hooks/x402Currency.ts | 44 ++++++++
website/locales/de.ts | 4 +
website/locales/en.ts | 4 +
website/test/AssistantChat.test.tsx | 39 ++++++-
website/test/failure.test.ts | 3 +
website/test/useX402Chat.test.ts | 149 +++++++++++++++++++++++++++
website/test/x402Currency.test.ts | 56 ++++++++++
website/test/x402PaidFetch.test.ts | 49 +++++++++
website/utils/x402PaidFetch.ts | 61 ++++++++++-
11 files changed, 472 insertions(+), 14 deletions(-)
diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx
index 44b2ac115..3e0b360e5 100644
--- a/website/components/AssistantChat.tsx
+++ b/website/components/AssistantChat.tsx
@@ -554,6 +554,10 @@ export function AssistantChat() {
const networkFallbackLabel = useLocale({ label: "assistent.networkFallback" });
const eurcBaseOnlyLabel = useLocale({ label: "payment.eurcBaseOnly" });
const currencyFallbackLabel = useLocale({ label: "payment.currencyFallback" });
+ // The wallet-side note: guarded-B fell back to a token the wallet can fund. Named apart from
+ // `payment.currencyFallback` (the agent-side note below) because the reasons differ — "the
+ // agent doesn't take it" and "your wallet holds none of it" must not share a sentence.
+ const walletFallbackLabel = useLocale({ label: "assistent.walletFallback" });
// Mobile detection
React.useEffect(() => {
@@ -704,6 +708,7 @@ export function AssistantChat() {
paymentReceipt,
paymentNetwork,
paymentCurrency,
+ currencyFallback,
status: chatStatus,
} = useX402Chat(desiredNetwork, agentUrl, currency);
const { network, switchIfNeeded, getSwitchError } = useAutoNetwork([paymentNetwork]);
@@ -1413,8 +1418,10 @@ export function AssistantChat() {
)}
{/* Separately from the network fallback above: the agent may accept the negotiated
- network but only in the other currency (e.g. USDC-only on Base). */}
- {paymentCurrency !== currency && (
+ network but only in the other currency (e.g. USDC-only on Base). Suppressed when
+ the wallet note is active — then the deviation is the wallet's, not the agent's,
+ and the wrong attribution would be worse than silence. */}
+ {paymentCurrency !== currency && !currencyFallback && (
{currencyFallbackLabel} {paymentCurrency}.
@@ -1599,6 +1606,17 @@ export function AssistantChat() {
))
)}
+ {currencyFallback && (
+ // One honest line when the send switched token: the deviation is announced, the
+ // stored preference stays untouched (the hook never persists a fallback), and
+ // the line quietly teaches where the currency setting lives.
+
+ {walletFallbackLabel
+ .replaceAll("{used}", currencyFallback.used)
+ .replaceAll("{preferred}", currencyFallback.preferred)}
+
+ )}
+
{isLoading && (
{isDefaultAgent &&
}
diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts
index 542a19b5f..a09f5a2e0 100644
--- a/website/hooks/useX402Chat.ts
+++ b/website/hooks/useX402Chat.ts
@@ -17,8 +17,8 @@ import { useWalletClient } from "wagmi";
import { getConfiguredPublicClient } from "./useConfiguredPublicClient";
import { useIsWalletConnected } from "./useIsWalletConnected";
import { probeAccepts, negotiateNetwork, LLM_V1_FLOOR } from "./x402Discovery";
-import { DEFAULT_CURRENCY, offeredCurrency, type PaymentCurrency } from "./x402Currency";
-import { createPaidFetch, type PaidFetch } from "../utils/x402PaidFetch";
+import { DEFAULT_CURRENCY, offeredCurrency, offeredCurrencies, selectFundedCurrency, type PaymentCurrency } from "./x402Currency";
+import { createPaidFetch, readFundableTokens, type PaidFetch } from "../utils/x402PaidFetch";
import type {
X402ChatMessage,
X402ChatResponse,
@@ -68,6 +68,14 @@ export interface UseX402ChatResult {
* `currency` until the agent has been probed.
*/
paymentCurrency: PaymentCurrency;
+ /**
+ * When the token this send would have paid in could not fund its deposit and another
+ * token the wallet holds and the agent offers was used instead: `{ preferred, used }`,
+ * per-payment and never persisted (the stored preference stays the user's alone). Null
+ * on every send that paid in the negotiated currency. The component announces it; the
+ * deposit signature was the consent gate.
+ */
+ currencyFallback: { preferred: PaymentCurrency; used: PaymentCurrency } | null;
}
/**
@@ -101,6 +109,11 @@ export function useX402Chat(
network: string;
paid: PaymentCurrency;
} | null>(null);
+ // The guarded-B fallback of the most recent send, or null — set per send, never persisted.
+ const [currencyFallback, setCurrencyFallback] = useState<{
+ preferred: PaymentCurrency;
+ used: PaymentCurrency;
+ } | null>(null);
const isCurrent =
negotiated?.agentUrl === agentUrl && negotiated?.preferred === network && negotiated?.currency === currency;
const paymentNetwork = isCurrent ? negotiated.network : network;
@@ -155,10 +168,6 @@ export function useX402Chat(
// that — proceed on the preferred network and let the real 402 be the judge.
const payNetwork = resolved ?? network;
const paid = offeredCurrency(accepts, payNetwork, currency) ?? currency;
- setNegotiated({ agentUrl, preferred: network, currency, network: payNetwork, paid });
- // Synchronously, unlike the state above: this turn's tool calls read it during the very
- // loop this send belongs to, long before a re-render could deliver the state.
- payRef.current = { network: payNetwork, currency };
// A readContract-capable client is required: batch-settlement's corrective-402
// recovery reads channel state on-chain, unlike the exact scheme. Resolved here, not
@@ -168,6 +177,27 @@ export function useX402Chat(
throw new Error(`No public client for network ${payNetwork}`);
}
+ // Guarded-B currency fallback (selectFundedCurrency in x402Currency.ts): when the
+ // token this send would pay in cannot fund its deposit floor, pay in a token the
+ // wallet actually holds and the agent actually offers. `paid` is passed, not the raw
+ // preference, so the seller-side negotiation runs first — a fallback can only replace
+ // a token that could not pay anyway, and never chooses among funded options. The
+ // switch is never persisted, the deposit signature stays the consent gate, and the
+ // component announces the deviation via `currencyFallback`.
+ const candidates = offeredCurrencies(accepts, payNetwork);
+ const fundable =
+ candidates.length > 0
+ ? await readFundableTokens(walletClient.account.address, publicClient, payNetwork)
+ : () => false;
+ const effective = selectFundedCurrency(paid, candidates, fundable);
+ setCurrencyFallback(effective.fellBack ? { preferred: paid, used: effective.currency } : null);
+ setNegotiated({ agentUrl, preferred: network, currency, network: payNetwork, paid: effective.currency });
+ // Synchronously, unlike the state above: this turn's tool calls read it during the very
+ // loop this send belongs to, long before a re-render could deliver the state. The
+ // effective currency, not the preference — the tools must spend on the channel this
+ // turn actually opened.
+ payRef.current = { network: payNetwork, currency: effective.currency };
+
setStatus("awaiting-signature");
setError(null);
// Don't clear paymentReceipt here: it represents the currently-open channel's
@@ -269,7 +299,18 @@ export function useX402Chat(
setPaymentReceipt(null);
}, []);
- return { sendMessage, paidFetch, status, error, paymentReceipt, reset, isReady, paymentNetwork, paymentCurrency };
+ return {
+ sendMessage,
+ paidFetch,
+ status,
+ error,
+ paymentReceipt,
+ reset,
+ isReady,
+ paymentNetwork,
+ paymentCurrency,
+ currencyFallback,
+ };
}
export default useX402Chat;
diff --git a/website/hooks/x402Currency.ts b/website/hooks/x402Currency.ts
index 8b603f11d..f44738730 100644
--- a/website/hooks/x402Currency.ts
+++ b/website/hooks/x402Currency.ts
@@ -108,3 +108,47 @@ export function offeredCurrency(
const first = onNetwork[0];
return first ? currencyOf(first) : null;
}
+
+/**
+ * The settlement currencies the agent offers on `network`, in `PAYMENT_CURRENCIES` order —
+ * the candidate list for `selectFundedCurrency`. A fallback is only ever chosen from what
+ * the seller actually takes, so a funded token it does not offer is never picked. Empty for
+ * an unreadable offer (`accepts === null`): with no candidates the fallback cannot fire,
+ * and the send proceeds exactly as before.
+ */
+export function offeredCurrencies(accepts: AcceptsEntry[] | null, network: string): PaymentCurrency[] {
+ if (!accepts) return [];
+ const onNetwork = accepts.filter((entry) => entry.network === network);
+ return PAYMENT_CURRENCIES.filter((currency) => onNetwork.some((entry) => currencyOf(entry) === currency));
+}
+
+/**
+ * The guarded-B currency fallback: pay in a token the wallet can actually fund, when the
+ * token the send would pay in cannot cover its deposit floor.
+ *
+ * Pure on purpose — the caller gathers the facts, this only decides:
+ * - `paid` is what the send would pay in after seller-side negotiation (`offeredCurrency`),
+ * not the raw user preference.
+ * - `candidates` are the tokens the seller offers on the pay network, in
+ * `PAYMENT_CURRENCIES` order (euro-first), so among two funded alternatives EURC beats USDC.
+ * - `fundable` answers "can the wallet balance cover this token's deposit floor".
+ *
+ * The rules, each of which is a guard agreed in the plan: the paid currency always wins when
+ * it can fund the deposit; a fallback is only ever chosen from what the seller actually offers
+ * (a fundable preference the seller does not take is unusable by construction); and when
+ * nothing is fundable the paid currency stands unchanged — the send then fails with a message
+ * that is honest exactly because this fallback ran first ("recharge" is only correct advice
+ * when no held token could fund it). A fallback never persists: the stored preference is the
+ * user's alone, and the deposit signature remains the consent gate for the new token.
+ */
+export function selectFundedCurrency(
+ paid: PaymentCurrency,
+ candidates: readonly PaymentCurrency[],
+ fundable: (currency: PaymentCurrency) => boolean,
+): { currency: PaymentCurrency; fellBack: boolean } {
+ if (candidates.includes(paid) && fundable(paid)) {
+ return { currency: paid, fellBack: false };
+ }
+ const funded = candidates.find((candidate) => fundable(candidate));
+ return funded ? { currency: funded, fellBack: true } : { currency: paid, fellBack: false };
+}
diff --git a/website/locales/de.ts b/website/locales/de.ts
index 04357b6a9..f5cca6918 100644
--- a/website/locales/de.ts
+++ b/website/locales/de.ts
@@ -178,6 +178,10 @@ export default {
analyticsSource: "Quelle: Analytics",
braveSource: "Quelle: Brave Search",
transitousSource: "Quelle: Transitous",
+ // Guarded-B: wenn die bevorzugte Währung die Einzahlung nicht decken kann, zahlt der Chat
+ // automatisch mit einer, die die Wallet hält — und sagt es hier, statt zu behaupten, man
+ // müsse erst EURe kaufen. Die Wallet-Signatur bleibt der Moment der Zustimmung.
+ walletFallback: "Mit {used} gezahlt — deine Wallet hält kein {preferred}. Umstellen unter Erweitert.",
errorPrefix: "Fehler:",
unknownError: "Unbekannter Fehler",
loading: "Lädt...",
diff --git a/website/locales/en.ts b/website/locales/en.ts
index a55a24d34..726ff43eb 100644
--- a/website/locales/en.ts
+++ b/website/locales/en.ts
@@ -169,6 +169,10 @@ export default {
analyticsSource: "Source: Analytics",
braveSource: "Source: Brave Search",
transitousSource: "Source: Transitous",
+ // Guarded-B: when the preferred currency cannot fund the deposit, the chat pays with one
+ // the wallet holds — and says so here, instead of claiming you need to buy EURe first.
+ // The wallet signature remains the moment of consent.
+ walletFallback: "Paid with {used} — your wallet holds no {preferred}. Switch under Advanced.",
errorPrefix: "Error:",
unknownError: "Unknown error",
loading: "Loading...",
diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx
index 205ba1839..7bc5d8edc 100644
--- a/website/test/AssistantChat.test.tsx
+++ b/website/test/AssistantChat.test.tsx
@@ -58,6 +58,7 @@ vi.mock("../hooks/useX402Chat", () => ({
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
+ currencyFallback: null,
})),
DEFAULT_LLM_AGENT_URL: "https://llm-agent.fretchen.eu",
}));
@@ -280,6 +281,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
+ currencyFallback: null,
});
vi.mocked(useWalletConnection).mockReturnValue({
address: "0x1234567890123456789012345678901234567890",
@@ -410,6 +412,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
+ currencyFallback: null,
});
mockSendMessage.mockImplementation(() => new Promise(() => {})); // never resolves: stay loading
@@ -494,6 +497,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "EURC",
+ currencyFallback: null,
});
renderWithQuery(
);
@@ -571,6 +575,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "EURC",
+ currencyFallback: null,
});
mockSendMessage.mockRejectedValueOnce(
new PaymentError(402, JSON.stringify({ error: "insufficient_balance" }), "EURC"),
@@ -918,6 +923,34 @@ describe("AssistantChat", () => {
expect(names).not.toContain("get_departures");
});
+ it("announces a wallet-driven currency switch, and does not blame the agent for it", () => {
+ const { unmount } = renderWithQuery(
);
+ expect(screen.queryByText(/assistent\.walletFallback/)).not.toBeInTheDocument();
+ unmount();
+
+ // EURe preferred, the wallet could only fund USDC — the hook fell back and reports it.
+ // paymentCurrency differs from the preference too, which alone would trigger the
+ // agent-side note; the wallet note must supersede it, or the UI would attribute the
+ // switch to the agent when it was the wallet.
+ vi.mocked(useX402Chat).mockReturnValue({
+ sendMessage: mockSendMessage,
+ paidFetch: mockPaidFetch,
+ status: "idle",
+ error: null,
+ paymentReceipt: null,
+ reset: vi.fn(),
+ isReady: true,
+ paymentNetwork: "eip155:10",
+ paymentCurrency: "USDC",
+ currencyFallback: { preferred: "EURe", used: "USDC" },
+ });
+
+ renderWithQuery(
);
+
+ expect(screen.getByText(/assistent\.walletFallback/)).toBeInTheDocument();
+ expect(screen.queryByText(/payment\.currencyFallback/)).not.toBeInTheDocument();
+ });
+
it("keeps the image tool on offer when a Bundestakt lookup fails", async () => {
// The regression this guards: a single shared `toolFailed` flag would have disabled
// generate_image for the rest of the turn because an unrelated, free lookup failed.
@@ -1532,7 +1565,8 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
- });
+ currencyFallback: null,
+ });
renderWithQuery(
);
@@ -1555,7 +1589,8 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
- });
+ currencyFallback: null,
+ });
renderWithQuery(
);
diff --git a/website/test/failure.test.ts b/website/test/failure.test.ts
index 10483d9e4..40e5cf2c2 100644
--- a/website/test/failure.test.ts
+++ b/website/test/failure.test.ts
@@ -20,6 +20,9 @@ describe("paymentFailed", () => {
expect(result?.status).toBe("payment_failed");
expect(result?.reason).toMatch(/USDC/i);
+ // The model's advice must point at the in-chat switch, not at buying the missing token —
+ // the guarded-B fallback makes this error a race-era residue, and the switch is the fix.
+ expect(result?.reason).toMatch(/switch the payment currency in the assistant's advanced settings/i);
});
/**
diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts
index 699205531..4b089b60c 100644
--- a/website/test/useX402Chat.test.ts
+++ b/website/test/useX402Chat.test.ts
@@ -58,6 +58,16 @@ vi.mock("@x402/evm/batch-settlement/client", () => ({
mockReadChannelBalanceAndTotalClaimed(signer, channelId),
}));
+// The funding-facts reader is unit-tested in x402PaidFetch.test.ts; here its answer is
+// injected, so these tests cover exactly the wiring: candidates, selection, announcement
+// state, and what reaches createPaidFetch. vi.hoisted because the factory below spreads
+// this in immediately — a plain const is still uninitialised when the hoisted vi.mock runs.
+const mockReadFundableTokens = vi.hoisted(() => vi.fn());
+vi.mock("../utils/x402PaidFetch", async (importOriginal) => {
+ const actual = await importOriginal
();
+ return { ...actual, readFundableTokens: mockReadFundableTokens };
+});
+
const NETWORK = "eip155:84532";
const mockWalletClient = {
account: { address: "0x1234567890123456789012345678901234567890" },
@@ -71,6 +81,9 @@ describe("useX402Chat", () => {
// Every case here probes the same agent URL; a cached hit would serve the previous case's
// accepts[] and silently defeat the per-case fetch mocks.
resetAcceptsCache();
+ // The default: everything fundable, so the paid currency always wins and the existing
+ // cases see today's behavior. Tests that need a fallback override this themselves.
+ mockReadFundableTokens.mockResolvedValue(() => true);
mockGetPaymentSettleResponse.mockReturnValue({
success: true,
transaction: "0xdeposit",
@@ -952,6 +965,142 @@ describe("useX402Chat", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
+
+ describe("guarded-B currency fallback", () => {
+ // LLM_V1_FLOOR is mainnet-only, and mainnet Base is the one network carrying all three
+ // settlement tokens — so this describe negotiates on eip155:8453, not the Sepolia NETWORK
+ // the rest of the file uses.
+ const PAY_BASE = "eip155:8453";
+ // Base Sepolia's registered settlement tokens (chain-utils' own addresses) — the asset
+ // field is what maps an accepts entry to a currency the site can pay with.
+ const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C";
+ const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42";
+ const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
+
+ const isProbe = (init?: RequestInit) => {
+ try {
+ return (JSON.parse(init?.body as string) as { model?: string }).model === "probe";
+ } catch {
+ return false;
+ }
+ };
+
+ /** An agent that 402s the probe with all three tokens offered on NETWORK, then succeeds. */
+ function stubAgentOfferingAllTokens() {
+ const accepts = [
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURE },
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURC },
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_USDC },
+ ];
+ const header = btoa(JSON.stringify({ accepts }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_input: string, init?: RequestInit) =>
+ Promise.resolve(
+ isProbe(init)
+ ? new Response("{}", { status: 402, headers: { "Payment-Required": header } })
+ : new Response(JSON.stringify({ content: "hi" }), { status: 200 }),
+ ),
+ ),
+ );
+ }
+
+ beforeEach(() => {
+ vi.mocked(useWalletClient).mockReturnValue(buildWalletClientData({ data: mockWalletClient }));
+ vi.mocked(useAccount).mockReturnValue(buildAccountData({ isConnected: true }));
+ });
+
+ it("keeps the paid currency and reports no fallback when it can fund the deposit", async () => {
+ stubAgentOfferingAllTokens();
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+
+ it("falls back to the first funded candidate and reports the deviation", async () => {
+ stubAgentOfferingAllTokens();
+ // EURe (the default and the paid currency) is unfunded; both EURC and USDC are funded,
+ // so the registry order picks EURC — the euro-first candidate.
+ mockReadFundableTokens.mockResolvedValue((c: string) => c === "EURC" || c === "USDC");
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURC");
+ expect(result.current.currencyFallback).toEqual({ preferred: "EURe", used: "EURC" });
+ });
+
+ it("keeps today's behavior when nothing is fundable — the failure wording is then honest advice", async () => {
+ stubAgentOfferingAllTokens();
+ mockReadFundableTokens.mockResolvedValue(() => false);
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+
+ it("never reads balances when the agent offer is unreadable — unknown must not switch tokens", async () => {
+ // No 402 on the probe: accepts is null, candidates are empty, and the read is skipped.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_input: string, init?: RequestInit) =>
+ Promise.resolve(
+ isProbe(init)
+ ? new Response("{}", { status: 200 })
+ : new Response(JSON.stringify({ content: "hi" }), { status: 200 }),
+ ),
+ ),
+ );
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(mockReadFundableTokens).not.toHaveBeenCalled();
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+
+ it("does not fall back to a token the agent does not offer, even when it is the only funded one", async () => {
+ // The agent offers EURe and USDC only; EURC is funded but unofferable.
+ const accepts = [
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURE },
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_USDC },
+ ];
+ const header = btoa(JSON.stringify({ accepts }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_input: string, init?: RequestInit) =>
+ Promise.resolve(
+ isProbe(init)
+ ? new Response("{}", { status: 402, headers: { "Payment-Required": header } })
+ : new Response(JSON.stringify({ content: "hi" }), { status: 200 }),
+ ),
+ ),
+ );
+ mockReadFundableTokens.mockResolvedValue((c: string) => c === "EURC");
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+ });
});
describe("WebStorageClientChannelStorage", () => {
diff --git a/website/test/x402Currency.test.ts b/website/test/x402Currency.test.ts
index c86aaae6b..ca0ff8a56 100644
--- a/website/test/x402Currency.test.ts
+++ b/website/test/x402Currency.test.ts
@@ -9,9 +9,12 @@ import {
DEFAULT_CURRENCY,
PAYMENT_CURRENCIES,
networksForCurrency,
+ offeredCurrencies,
preferCurrency,
readCurrency,
+ selectFundedCurrency,
storeCurrency,
+ type PaymentCurrency,
} from "../hooks/x402Currency";
const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C" as const;
@@ -97,3 +100,56 @@ describe("preferCurrency selector", () => {
expect(paid).toBe("EURe");
});
});
+
+describe("selectFundedCurrency", () => {
+ /** `fundable` answers "can this token's wallet balance cover the deposit floor". */
+ const funded = (...held: PaymentCurrency[]) => (c: PaymentCurrency) => held.includes(c);
+ const all = ["EURe", "EURC", "USDC"] as const;
+
+ it("keeps the paid currency when it can fund the deposit", () => {
+ expect(selectFundedCurrency("EURe", all, () => true)).toEqual({ currency: "EURe", fellBack: false });
+ });
+
+ it("falls back to the first funded candidate in preference order — EURC before USDC", () => {
+ expect(selectFundedCurrency("EURe", all, funded("EURC", "USDC"))).toEqual({ currency: "EURC", fellBack: true });
+ });
+
+ it("skips an unfunded candidate to the next funded one", () => {
+ expect(selectFundedCurrency("EURe", all, funded("USDC"))).toEqual({ currency: "USDC", fellBack: true });
+ });
+
+ it("keeps today's behavior when nothing is fundable — the failure wording is then honest advice", () => {
+ expect(selectFundedCurrency("EURe", all, () => false)).toEqual({ currency: "EURe", fellBack: false });
+ });
+
+ it("keeps the paid currency when no candidates are offered", () => {
+ expect(selectFundedCurrency("USDC", [], () => true)).toEqual({ currency: "USDC", fellBack: false });
+ });
+
+ it("never picks a currency the seller does not offer, even when its balance would fund it", () => {
+ expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({ currency: "USDC", fellBack: true });
+ });
+});
+
+describe("offeredCurrencies", () => {
+ it("lists the seller's offered tokens on the network, in preference order", () => {
+ const accepts = [
+ accept("eip155:8453", BASE_USDC),
+ accept("eip155:8453", BASE_EURE),
+ accept("eip155:8453", BASE_EURC),
+ ];
+ expect(offeredCurrencies(accepts, "eip155:8453")).toEqual(["EURe", "EURC", "USDC"]);
+ });
+
+ it("ignores offers on other networks and tokens this site does not pay with", () => {
+ const accepts = [
+ accept("eip155:10", BASE_USDC),
+ accept("eip155:8453", "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), // not a settlement token
+ ];
+ expect(offeredCurrencies(accepts, "eip155:8453")).toEqual([]);
+ });
+
+ it("is empty for an unreadable offer — no candidates, no fallback", () => {
+ expect(offeredCurrencies(null, "eip155:8453")).toEqual([]);
+ });
+});
diff --git a/website/test/x402PaidFetch.test.ts b/website/test/x402PaidFetch.test.ts
index 40031c472..bad777f38 100644
--- a/website/test/x402PaidFetch.test.ts
+++ b/website/test/x402PaidFetch.test.ts
@@ -45,6 +45,7 @@ import {
PaymentError,
WebStorageClientChannelStorage,
readChargedTotal,
+ readFundableTokens,
} from "../utils/x402PaidFetch";
import { formatSpend } from "../utils/x402Prices";
@@ -355,3 +356,51 @@ describe("formatSpend", () => {
expect(formatSpend(1_000n, "EURC")).toBe("€0.001 EURC");
});
});
+
+describe("readFundableTokens", () => {
+ // Base carries all three settlement tokens; the addresses are chain-utils' own.
+ const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C";
+ const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42";
+ const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
+ const wallet = "0x1234567890123456789012345678901234567890" as `0x${string}`;
+
+ /** A public client that answers `balanceOf` from a per-address table. */
+ function clientWith(balances: Record) {
+ return {
+ readContract: vi.fn(async ({ address }: { address: string }) => {
+ const balance = balances[address];
+ if (balance === "boom") throw new Error("RPC down");
+ return balance;
+ }),
+ };
+ }
+
+ it("funds only the tokens whose wallet balance covers the deposit floor", async () => {
+ // 0 EURe, 1.00 EURC (>= 0.50), 0.10 USDC (< 0.50).
+ const client = clientWith({
+ [BASE_EURE]: 0n,
+ [BASE_EURC]: 1_000_000n,
+ [BASE_USDC]: 100_000n,
+ });
+
+ const fundable = await readFundableTokens(wallet, client, "eip155:8453");
+
+ expect(fundable("EURe")).toBe(false);
+ expect(fundable("EURC")).toBe(true);
+ expect(fundable("USDC")).toBe(false);
+ });
+
+ it("treats a failing read as unfunded — unknown must not trigger a fallback", async () => {
+ const client = clientWith({
+ [BASE_EURE]: "boom",
+ [BASE_EURC]: "boom",
+ [BASE_USDC]: "boom",
+ });
+
+ const fundable = await readFundableTokens(wallet, client, "eip155:8453");
+
+ expect(fundable("EURe")).toBe(false);
+ expect(fundable("EURC")).toBe(false);
+ expect(fundable("USDC")).toBe(false);
+ });
+});
diff --git a/website/utils/x402PaidFetch.ts b/website/utils/x402PaidFetch.ts
index 739791c3d..0fe69c531 100644
--- a/website/utils/x402PaidFetch.ts
+++ b/website/utils/x402PaidFetch.ts
@@ -1,4 +1,5 @@
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
+import { getSettlementTokens } from "@fretchen/chain-utils";
import { buildStablecoinAllowedAssets } from "../hooks/x402SpendControls";
import { CURRENCY_DECIMALS, preferCurrency, type PaymentCurrency } from "../hooks/x402Currency";
// Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR.
@@ -215,6 +216,53 @@ export function minimumDepositAtomic(currency: PaymentCurrency): bigint {
return 5n * 10n ** BigInt(CURRENCY_DECIMALS[currency] - 1);
}
+/**
+ * Which settlement tokens this wallet could fund a *new* channel deposit in — the funding
+ * facts behind `selectFundedCurrency` (hooks/x402Currency.ts). Reads `balanceOf` for every
+ * token registered on `network` (three parallel reads), and a token counts as fundable when
+ * the balance covers its `minimumDepositAtomic` floor — the same 0.50 the deposit itself
+ * would move, so the answer cannot drift from what the SDK will actually ask for.
+ *
+ * A failing read (RPC down, unknown token) counts as *unfunded*, never as "try another":
+ * "unknown" must not trigger a currency fallback, or a flaky RPC would silently switch the
+ * token the user chose. Per-call state by design — balances are read fresh at send time,
+ * never cached, matching the "in context of the action" rule of the chain switch.
+ *
+ * `publicClient` is `unknown` and cast inside, the file's own convention: viem's generic
+ * `readContract` cannot be matched structurally, and `createPaidFetch` casts the same value
+ * for the same reason (see the call at `toClientEvmSigner`).
+ */
+export async function readFundableTokens(
+ walletAddress: `0x${string}`,
+ // Unknown, cast below to the minimal shape actually called: viem's generic readContract
+ // cannot be matched structurally, and `createPaidFetch` accepts the same value as unknown
+ // for the same reason. The cast keeps this function any-free.
+ publicClient: unknown,
+ network: string,
+): Promise<(currency: PaymentCurrency) => boolean> {
+ const client = publicClient as { readContract: (call: unknown) => Promise };
+ const tokens = getSettlementTokens(network);
+ const held = new Map();
+ await Promise.all(
+ tokens.map(async (token) => {
+ try {
+ const balance = await client.readContract({
+ address: token.address,
+ functionName: "balanceOf",
+ args: [walletAddress],
+ });
+ held.set(token.symbol, typeof balance === "bigint" ? balance : null);
+ } catch {
+ held.set(token.symbol, null);
+ }
+ }),
+ );
+ return (currency) => {
+ const balance = held.get(currency);
+ return balance !== undefined && balance !== null && balance >= minimumDepositAtomic(currency);
+ };
+}
+
/**
* Custom deposit sizing: always deposit/top-up to at least `minimumDepositAtomic(currency)`,
* regardless of the SDK's default multiplier-of-ceiling formula — see that function's
@@ -274,10 +322,17 @@ function describePaymentError(status: number, body: string, currency: PaymentCur
return DRAINED_CHANNEL_MESSAGE.chat;
}
if (errorCode?.includes("insufficient_balance")) {
- // The USDC.e note is USDC's alone: bridged EURC is not a thing people end up holding by accident.
+ // The switch hint is the actionable advice: the guarded-B fallback (useX402Chat) has
+ // already moved the send to a funded token when one exists, so reaching this message
+ // means either a race between the balance read and the deposit, or nothing funded — and
+ // the hint points at the one in-chat fix either way, instead of "go buy ".
+ // Both variants are written to fit `describeFailure`'s 200-char cap: the hint is the part
+ // the model must not lose, and the USDC.e note is shortened, not the hint.
+ // (USDC's note is USDC's alone: bridged EURC is not a thing people end up holding by accident.)
+ const switchHint = "Alternatively, switch the payment currency in the assistant's advanced settings.";
return currency === "USDC"
- ? "Not enough USDC in your wallet to fund the payment channel. Note that only native USDC works — a bridged variant such as USDC.e cannot be used."
- : `Not enough ${currency} in your wallet to fund the payment channel.`;
+ ? `Not enough USDC in your wallet to fund the payment channel. Note that only native USDC works, not USDC.e. ${switchHint}`
+ : `Not enough ${currency} in your wallet to fund the payment channel. ${switchHint}`;
}
return `Request failed: ${status} - ${body}`;
}
From ddb083d03e027b1b1e6be265e895649321e0a9a1 Mon Sep 17 00:00:00 2001
From: fretchen
Date: Fri, 16 Oct 2026 18:14:40 +0200
Subject: [PATCH 4/5] fix names
---
website/components/AssistantChat.tsx | 3 ++-
website/pages/assistent/+description.ts | 2 +-
website/pages/assistent/+title.ts | 2 +-
website/pages/lab/+Page.tsx | 2 +-
4 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx
index 3e0b360e5..bb094f0b8 100644
--- a/website/components/AssistantChat.tsx
+++ b/website/components/AssistantChat.tsx
@@ -1,6 +1,7 @@
/**
* AssistantChat — the interactive core of the x402 batch-settlement chat assistant
- * (`/assistent`). Pays per message via `useX402Chat` (USDC payment channels): the
+ * (`/assistent`). Pays per message via `useX402Chat` (stablecoin payment channels —
+ * EURe by default, EURC and USDC one toggle away): the
* first message opens a channel (one wallet-signed deposit), later messages are
* off-chain voucher signatures reusing the open channel.
*/
diff --git a/website/pages/assistent/+description.ts b/website/pages/assistent/+description.ts
index 5100894f7..e69288f1b 100644
--- a/website/pages/assistent/+description.ts
+++ b/website/pages/assistent/+description.ts
@@ -1,3 +1,3 @@
export function description() {
- return "An AI assistant that pays per message with x402 batch-settlement USDC payment channels — no prepaid balance, no subscription. The channel opens on your first message; each message after is a cheap off-chain voucher.";
+ return "An AI assistant that pays per message with x402 batch-settlement stablecoin payment channels — no prepaid balance, no subscription. The channel opens on your first message; each message after is a cheap off-chain voucher.";
}
diff --git a/website/pages/assistent/+title.ts b/website/pages/assistent/+title.ts
index 25d4e49a7..e2eb64ccb 100644
--- a/website/pages/assistent/+title.ts
+++ b/website/pages/assistent/+title.ts
@@ -1,3 +1,3 @@
export function title() {
- return "AI Assistant with USDC Payment Channels | fretchen.eu";
+ return "AI Assistant with Stablecoin Payment Channels | fretchen.eu";
}
diff --git a/website/pages/lab/+Page.tsx b/website/pages/lab/+Page.tsx
index 08d6ca68b..f805d2673 100644
--- a/website/pages/lab/+Page.tsx
+++ b/website/pages/lab/+Page.tsx
@@ -21,7 +21,7 @@ const LabPage: React.FC = () => {
From a6379b6b686efb2cd7ab429c41b799020ec3b189 Mon Sep 17 00:00:00 2001
From: fretchen
Date: Fri, 16 Oct 2026 21:21:46 +0200
Subject: [PATCH 5/5] make it prettier
---
website/hooks/useX402Chat.ts | 8 +++++++-
website/test/AssistantChat.test.tsx | 4 ++--
website/test/x402Currency.test.ts | 10 ++++++++--
3 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts
index a09f5a2e0..8857126fc 100644
--- a/website/hooks/useX402Chat.ts
+++ b/website/hooks/useX402Chat.ts
@@ -17,7 +17,13 @@ import { useWalletClient } from "wagmi";
import { getConfiguredPublicClient } from "./useConfiguredPublicClient";
import { useIsWalletConnected } from "./useIsWalletConnected";
import { probeAccepts, negotiateNetwork, LLM_V1_FLOOR } from "./x402Discovery";
-import { DEFAULT_CURRENCY, offeredCurrency, offeredCurrencies, selectFundedCurrency, type PaymentCurrency } from "./x402Currency";
+import {
+ DEFAULT_CURRENCY,
+ offeredCurrency,
+ offeredCurrencies,
+ selectFundedCurrency,
+ type PaymentCurrency,
+} from "./x402Currency";
import { createPaidFetch, readFundableTokens, type PaidFetch } from "../utils/x402PaidFetch";
import type {
X402ChatMessage,
diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx
index 7bc5d8edc..86d983fe7 100644
--- a/website/test/AssistantChat.test.tsx
+++ b/website/test/AssistantChat.test.tsx
@@ -1566,7 +1566,7 @@ describe("AssistantChat", () => {
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
currencyFallback: null,
- });
+ });
renderWithQuery();
@@ -1590,7 +1590,7 @@ describe("AssistantChat", () => {
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
currencyFallback: null,
- });
+ });
renderWithQuery();
diff --git a/website/test/x402Currency.test.ts b/website/test/x402Currency.test.ts
index ca0ff8a56..087b216b5 100644
--- a/website/test/x402Currency.test.ts
+++ b/website/test/x402Currency.test.ts
@@ -103,7 +103,10 @@ describe("preferCurrency selector", () => {
describe("selectFundedCurrency", () => {
/** `fundable` answers "can this token's wallet balance cover the deposit floor". */
- const funded = (...held: PaymentCurrency[]) => (c: PaymentCurrency) => held.includes(c);
+ const funded =
+ (...held: PaymentCurrency[]) =>
+ (c: PaymentCurrency) =>
+ held.includes(c);
const all = ["EURe", "EURC", "USDC"] as const;
it("keeps the paid currency when it can fund the deposit", () => {
@@ -127,7 +130,10 @@ describe("selectFundedCurrency", () => {
});
it("never picks a currency the seller does not offer, even when its balance would fund it", () => {
- expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({ currency: "USDC", fellBack: true });
+ expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({
+ currency: "USDC",
+ fellBack: true,
+ });
});
});