import type {
MBTAResponse,
MBTASchedule,
MBTAPrediction,
MBTAIncluded,
TrainDeparture,
} from "./types";
import { STOP_NAMES } from "./stops";
const MBTA_BASE_URL = "https://api-v3.mbta.com";
const WORCESTER_ROUTE = "CR-Worcester";
export class MBTAClient {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
private async fetch<T>(
endpoint: string,
params: Record<string, string>
): Promise<T> {
const url = new URL(endpoint, MBTA_BASE_URL);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
const response = await fetch(url.toString(), {
headers: {
"x-api-key": this.apiKey,
Accept: "application/vnd.api+json",
},
});
if (!response.ok) {
throw new Error(
`MBTA API error: ${response.status} ${response.statusText}`
);
}
return response.json();
}
async getSchedules(
stopId: string,
directionId: 0 | 1,
date: string
): Promise<MBTAResponse<MBTASchedule>> {
return this.fetch<MBTAResponse<MBTASchedule>>("/schedules", {
"filter[route]": WORCESTER_ROUTE,
"filter[stop]": stopId,
"filter[direction_id]": String(directionId),
"filter[date]": date,
include: "trip,stop",
sort: "departure_time",
});
}
async getPredictions(
stopId: string,
directionId: 0 | 1
): Promise<MBTAResponse<MBTAPrediction>> {
return this.fetch<MBTAResponse<MBTAPrediction>>("/predictions", {
"filter[route]": WORCESTER_ROUTE,
"filter[stop]": stopId,
"filter[direction_id]": String(directionId),
include: "trip,stop,vehicle",
});
}
async getDepartures(
stopId: string,
directionId: 0 | 1,
date?: string
): Promise<TrainDeparture[]> {
const targetDate = date || new Date().toISOString().split("T")[0];
// Fetch both schedules and predictions in parallel
const [scheduleResponse, predictionResponse] = await Promise.all([
this.getSchedules(stopId, directionId, targetDate),
this.getPredictions(stopId, directionId).catch(() => ({
data: [],
included: [],
jsonapi: { version: "1.0" },
})),
]);
// Build lookup maps from included data
const trips = new Map<string, { name: string; headsign: string }>();
const allIncluded: MBTAIncluded[] = [
...(scheduleResponse.included || []),
...(predictionResponse.included || []),
];
for (const item of allIncluded) {
if (item.type === "trip") {
trips.set(item.id, {
name: item.attributes.name,
headsign: item.attributes.headsign,
});
}
}
// Build prediction lookup by trip ID
const predictions = new Map<string, MBTAPrediction>();
for (const pred of predictionResponse.data) {
const tripId = pred.relationships.trip.data.id;
predictions.set(tripId, pred);
}
// Process schedules into departures
const departures: TrainDeparture[] = [];
const now = new Date();
for (const schedule of scheduleResponse.data) {
const tripId = schedule.relationships.trip.data.id;
const trip = trips.get(tripId);
const prediction = predictions.get(tripId);
const scheduledTime = schedule.attributes.departure_time;
if (!scheduledTime) continue;
const scheduledDate = new Date(scheduledTime);
// Skip past departures (more than 5 minutes ago)
if (scheduledDate < new Date(now.getTime() - 5 * 60 * 1000)) continue;
const predictedTime = prediction?.attributes.departure_time;
const predictedDate = predictedTime ? new Date(predictedTime) : null;
let delayMinutes: number | null = null;
let isDelayed = false;
if (predictedDate && scheduledDate) {
delayMinutes = Math.round(
(predictedDate.getTime() - scheduledDate.getTime()) / 60000
);
isDelayed = delayMinutes > 2; // Consider >2 min as delayed
}
let status = "On time";
if (prediction?.attributes.status) {
status = prediction.attributes.status;
} else if (isDelayed && delayMinutes) {
status = `${delayMinutes} min late`;
}
// Determine destination from trip headsign or infer from direction
let destination = trip?.headsign || "";
if (!destination) {
destination = directionId === 0 ? "Worcester" : "South Station";
}
departures.push({
trainNumber: trip?.name || "Unknown",
destination,
scheduledDeparture: scheduledTime,
predictedDeparture: predictedTime || null,
status,
track: null, // MBTA doesn't reliably provide track info in API
isDelayed,
delayMinutes,
});
}
// Sort by actual departure time (predicted or scheduled)
departures.sort((a, b) => {
const timeA = a.predictedDeparture || a.scheduledDeparture;
const timeB = b.predictedDeparture || b.scheduledDeparture;
return new Date(timeA).getTime() - new Date(timeB).getTime();
});
return departures;
}
}
// Singleton instance
let client: MBTAClient | null = null;
export function getMBTAClient(): MBTAClient {
if (!client) {
const apiKey = process.env.MBTA_API_KEY;
if (!apiKey) {
throw new Error("MBTA_API_KEY environment variable is required");
}
client = new MBTAClient(apiKey);
}
return client;
}