MBTA Transit Dashboard
Overview
real-time MBTA dashboard that shows when your train is actually coming. also weather and some news headlines I guess but the departures are the whole point. built it as a single static page backed by a proxy that smashes a few APIs into one response so no API key ever touches a browser, that part is kind of important honestly. it runs in two places — publicly at mbtadash.nbkelley.com and as a 24/7 kiosk on a Raspberry Pi at work. same codebase for both just totally different ways of getting it onto a screen
Why I Built It
I take the T to work and got real tired of opening an app every time I wanted to know if I should leave now or could finish what I was doing. the posted schedule is basically fiction so you cant trust that. wanted something I could glance at and immediately know. and I wanted it to look decent on a screen all day, not like some ugly utility panel from 2006
the two channel thing wasnt planned at all, it just kind of happened. mbtadash.nbkelley.com was the original, built it for myself to pull up on my phone at home and honestly thats still the one I actually use. then at some point I realized the same page would work as a permanent display at the office, so I threw it on a Raspberry Pi running Anthias pointed at an internal nginx server. same dashboard two totally different lives and I didnt have to build anything twice
How It Works
the whole thing is a static HTML page with some CSS and vanilla JS. no framework no build step no nothing. one request to /api/data gets the browser everything it needs, all the departures and weather and news in a single JSON blob. the heavy lifting is a tiny Node/Express proxy that goes out and fetches from three APIs in parallel, then normalizes whatever comes back and caches it
| Component | Role |
|---|---|
| Static frontend | Single HTML page, vanilla CSS/JS, portrait-first layout |
| Node/Express proxy | Merges MBTA v3, OpenWeatherMap, and RSS feeds into one /api/data endpoint |
| Nginx | Serves static files and reverse-proxies /api/ to Express |
| pm2 + systemd | Keeps the proxy alive across reboots |
the proxy
the proxy is basically the only moving part. fires off three fetches at the same time so the slowest one sets the pace instead of adding them all up
| Source | What I get from it | Cache TTL |
|---|---|---|
| MBTA v3 API | Real-time departure predictions and route patterns | 30s |
| OpenWeatherMap | Current conditions and daily forecast | 10 min |
| RSS feeds | MassLive Boston and State House News, first few items each | 5 min |
API keys live in a .env file outside the git repo, loaded when the process starts. a git pull cant touch them. took me one bad draft with the key sitting right in server.js to figure out why that mattered
the responses are cached in memory. if the RSS feed is slow whatever, departures and weather still come through. the client just gets one clean object, no chained fetches no nothing
mbtadash.nbkelley.com — the home channel
this is the one I actually use every day. deployed through Cloudflare and styled to match the rest of nbkelley.com so it doesnt look like some separate project. when Im at home wondering if I should leave now or finish my coffee I pull it up on my phone. its public but the API keys are all server-side so theres nothing sensitive happening in the browser at all
the work kiosk
at work the same dashboard runs as a permanent display on a Raspberry Pi 3B+ with Anthias, which is that digital signage thing that used to be called Screenly OSE. portrait 1080x1920 mounted on the wall always on. this one is completely internal, served from an nginx box at the office. not on my home network and definitely not public. just transit.intra.plgt.com on the work LAN
the Pi 3B+ has 788MB of RAM total. thats not a lot. Anthias runs in Docker and eats most of it so youre really working with nothing. every optimization decision in the frontend was basically me staring at htop and trying to make numbers go down
- No Leaflet.js. the first version had Leaflet with OpenStreetMap tiles for the T map and it looked nice until you watched the Pi try to actually run it. all those tile fetches and DOM nodes add up fast. swapped it out for a static WebP image. same info zero runtime cost. sometimes the right answer is just a PNG and you move on
- CSS animations over JS. the header ticker that scrolls when text overflows uses
transform: translateX()in pure CSS instead of a JavaScript interval. keeps the whole thing on the compositor thread which matters when the browser is refreshing live data every 30 seconds on hardware that has no business running a browser - server-side everything. the browser makes one request. all the MBTA and weather and RSS stuff happens on the proxy. the Pi just renders what it gets handed and that alone is kind of a lot for it
Anthias uses Qt WebEngine for rendering instead of actual Chrome and it has its own weird little quirks. fonts render wider so everything is set to Arial. WebP support is shaky, had to just try it and see. CSS animations are choppier than youd think. and emoji rendering basically doesnt work at all so all the icons are Bootstrap Icons instead. Qt WebEngine debugging was honestly just me going “why does it look like that” over and over until I fixed enough things
theres a cron job that restarts the Anthias viewer container every 6 hours to clear out whatever memory has built up. not elegant. works fine.
station grouping was wrong the first time
the first version grouped everything by line. all Red Line together all Orange Line together. looked nice on a diagram and made absolutely zero sense when you actually use it. when youre standing at Park Street you dont care what color the train is, you care what time the next one leaves from the station youre at
the rewrite groups by physical station. each card gets a header with the station name. departures inside sort by time with a little colored line pill, RL OL BL GL-B all that. the pills use the official MBTA colors and theyre the only color on an otherwise monochrome card, your eye just goes straight to them
departures are filtered to a 10 minute walking radius from wherever Im starting. not showing me trains I cant walk to in time
layout
portrait 1080x1920. 2-column CSS Grid for the station cards with a full-width static T map underneath. cards flex their height to content so a shorter card doesnt leave dead space
design iterations
the thing went through four visual designs:
- Line-based cards with big MBTA color backgrounds — horizontal layout meant for a TV. colors were kinda loud and grouping by line just wasnt useful
- Minimalist white with grey dividers — MBTA colors only on the small line pills. cleaner but still had the line grouping problem
- Station-based cards — finally fixed the grouping. cards represent physical stations with all routes listed per station. way more useful immediately
- Portrait 2-column grid — where it landed. flex columns, time-sorted departures, line pills, full-width map at the bottom
Challenges
- grouped departures by line at first because the API returns them that way and it seemed like the obvious thing to do. problem is nobody navigates the T by line color, they navigate by the station theyre standing in. the real issue was that the MBTA v3 API sends route patterns keyed by route ID not by stop, so to group by station I had to take the predictions endpoint and cross-reference it with the stops endpoint and merge them in the proxy before the client ever sees it. that restructured the whole data flow
- API key in server.js on the first draft. the Express proxy reads it from process.env but I had the .env file inside the git repo. one push later and its on GitHub. moved /opt/mbta-proxy/.env outside the repo entirely and loaded it with pm2 –env so server.js can be overwritten without losing the key
- the proxy merges MBTA v3 predictions, OpenWeatherMap One Call, and two RSS feeds into one response at /api/data. the problem is the MBTA API responds in about 200ms and the RSS feeds sometimes take 8 seconds or just hang. wrote a per-source timeout wrapper so a stalled feed doesnt block the whole response, the client gets whatever was ready
- the Pi 3B+ has 788MB of ram total. Anthias runs in Docker with the screenly-anthias-viewer-1 container and between that and the OS the browser gets maybe 80MB to work with. I swapped Leaflet.js for a static WebP because the tile fetches were triggering garbage collection constantly and you could see the display stutter every time the map panned. the CSS ticker is on translateX instead of scrollLeft so the animation stays on the compositor, no layout thrashing
- Qt WebEngine in Anthias uses a different font rasterizer than desktop Chrome so Arial renders about 15% wider and line heights are slightly off. WebP was supposed to be supported but the Qt build Anthias ships with is compiled without it on the 3B+ image so I had to fall back to PNG for the static map. and there is no devtools on a kiosk so debugging is just curl the page and guess
Result
the dashboard pulls live departures weather and news through the proxy and renders everything in one paint. I check mbtadash.nbkelley.com on my phone at home and the Pi at work runs it 24/7 on the wall with a cron job doing a docker restart on the viewer container every 6 hours to flush memory. same static page and same Express proxy serving both, theres a Cloudflare tunnel for the public one and an internal nginx instance on the work VLAN for the kiosk. someday Ill probably replace the Pi but the 3B+ has been doing this for months and its fine