From f3b3fab59aca06ffa223b3a6dcf67e65ba1f9cec Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 15 Oct 2026 07:43:44 +0200 Subject: [PATCH 1/2] 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/2] 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;