Building a Layover Calculator That Knows Every Terminal at JFK
How I built a connection time calculator covering 70 airports with pairwise terminal transfers, customs buffers, and a five-factor assessment algorithm, then spent half the day fixing dark mode.
I already had 70 airports in the database. Each one had terminals, TSA wait times, lounges, ground transport, and layover guidance. The data was there because we built airport guides a few weeks earlier, and the research had gone deep. Minimum connection times were sitting in every airport record. The question “is my layover long enough?” was answerable with the data I already had.
So I built a calculator that answers it. Not a lookup table, not a blog post that says “most experts recommend 90 minutes.” A tool that takes your airport, your connection type, your terminal, and your layover duration, then tells you whether to relax or run.
What it does
The connection time calculator returns one of four verdicts: illegal (below the airport’s minimum connection time), tight (meets the legal minimum but leaves thin margins), comfortable (well above the recommended buffer), or long enough to leave the airport (6+ hours at airports with good city center access).
It factors in five things: the airport’s published MCT for your connection type (domestic-to-domestic, international-to-domestic, etc.), the terminal transfer time if you are changing terminals, customs and immigration wait time if arriving internationally, TSA re-screening if you leave the secure area, and checked-bag recheck time if you have bags on an international arrival.
The full page has a sortable comparison table of all 70 airports, 13 real-world scenario cards (“Is 60 minutes enough at JFK domestic-to-domestic?” with a verdict and reasoning), customs wait times broken out by region, and a 14-question FAQ. The calculator itself also ships as a free embeddable widget for travel blogs.
How I worked with Claude on this one
This was a tight back-and-forth in Claude Code. I described a section, Claude built it, I reviewed and redirected, repeat. The pace was fast because the widget infrastructure already existed from the carry-on and checked bag fee widgets. The iframe resize handshake, the embed page template, the customizer layout with live preview, the URL parameter validation patterns: all of that carried over.
The new work was the assessment algorithm, the enrichment data layer (terminal transfers and customs buffers for every airport), and bulking up the tool page from a bare calculator to 770+ lines with depth. I wrote a handoff document specifying seven scope items and the patterns to copy from sibling tool pages. Claude executed against it one section at a time while I verified data accuracy and caught theme bugs.
The stack
Same as the other widgets: Astro 5.x, fully static, Cloudflare Pages. The widget component lives at src/components/embed/ConnectionTimeWidget.astro with all CSS and JavaScript inline. Airport data gets compressed into short keys before serializing to a JSON script tag at build time:
const airports = allAirports.map(a => ({
slug: a.slug,
iata: a.iata,
name: a.name,
city: a.city,
tc: a.terminalCount,
terms: a.terminals.map(t => t.name),
mct: {
dd: a.minConnectionTimes.domesticToDomesticMin,
di: a.minConnectionTimes.domesticToIntlMin,
id: a.minConnectionTimes.intlToDomesticMin,
ii: a.minConnectionTimes.intlToIntlMin,
ac: a.minConnectionTimes.airsideConnected,
ts: a.minConnectionTimes.transferSystem,
},
tsa: { peak: a.tsa.typicalWaitMinPeak, offpeak: a.tsa.typicalWaitMinOffPeak },
enriched: hasEnrichment(a.slug),
}));
Each airport has about 50 fields in the full data model. The widget needs 15. The compression cuts the serialized payload roughly in half, which matters when you are shipping 70 airports inline.
The hard part: the enrichment data layer
The trickiest part of the entire build was not the algorithm or the UI. It was the data underneath.
Every airport already had a minimum connection time. But MCT alone does not tell you much. A 45-minute MCT at LAX means something very different depending on whether you are walking between gates in Terminal 4 or taking a shuttle from TBIT to Terminal 7 and re-clearing TSA. I needed pairwise terminal transfer data for multi-terminal airports, and customs wait estimates that were airport-specific rather than “30 minutes everywhere.”
The result was connection-enrichment.json, 516 lines of structured data covering all 70 airports:
{
"dfw": {
"terminalTransfers": [
{ "from": "Terminal A", "to": "Terminal B", "minutes": 8, "mode": "Skylink", "airside": true },
{ "from": "Terminal A", "to": "Terminal C", "minutes": 10, "mode": "Skylink", "airside": true },
{ "from": "Terminal A", "to": "Terminal D", "minutes": 12, "mode": "Skylink", "airside": true },
{ "from": "Terminal C", "to": "Terminal D", "minutes": 6, "mode": "Skylink", "airside": true }
],
"customs": {
"typicalMinPeak": 35,
"typicalMinOffPeak": 15,
"globalEntryMin": 5,
"notes": "International arrivals clear customs in Terminal D."
}
}
}
Each transfer record captures the terminal pair, the transfer time in minutes, the mode (Skylink, Plane Train, AirTrain, shuttle bus, underground train, walking), and whether you stay airside. That last field is critical. If airside is false, you leave the secure area and need TSA re-screening, which adds 10 to 35 minutes depending on the airport and time of day.
The assessment function layers all five factors into a single recommended buffer:
const recommendedBufferMin =
Math.max(legalMin, terminalTime + 30)
+ customsBuffer
+ securityBuffer
+ bagRecheckBuffer;
The Math.max is doing real work. At some airports, the terminal transfer time plus a 30-minute cushion exceeds the published MCT. At others, the MCT already accounts for the transfer. Taking the larger of the two prevents the calculator from recommending less time than the airport’s own legal minimum.
Where Claude surprised me
The enrichment data. I expected to spend a full day researching terminal transfers and customs waits for 70 airports. Claude produced the entire 516-line JSON file in one pass: pairwise transfers for the top 20 hubs (DFW has 9 terminal pairs, JFK has 10, LAX has 9), customs peak and off-peak estimates for all 70 airports, Global Entry times, and notes explaining the specifics at each airport.
The data was not perfect. I spot-checked a dozen airports against official sources and found the numbers were in the right range. JFK’s T1-to-T4 AirTrain transfer at 15 minutes matched. ATL’s Plane Train at 15 minutes between domestic and international was right. CDG’s CDGVAL times were reasonable. The customs estimates tracked with CBP’s published wait time data for US airports.
I was prepared to write all of this by hand. Claude doing it in one shot saved hours, and the structure it chose (pairwise terminal pairs with an airside flag per transfer) was exactly the schema I would have designed. That does not always happen. Sometimes Claude picks a schema that works but is awkward to query. This time it nailed the access pattern.
Where Claude fell short
Dark mode. Twice.
The first problem was in the tool page itself. Claude used hardcoded Tailwind color classes throughout: text-emerald-400 for success badges, text-rose-400 for danger, text-amber-400 for warnings. These look fine in dark mode. They look terrible in light mode because the 400-weight palette is designed for dark backgrounds. The site uses a data-theme attribute on the HTML element, not prefers-color-scheme, so these classes need to adapt per theme.
I caught this when I toggled the site to light mode and saw pale-on-white text everywhere. Claude replaced all the hardcoded classes with theme-aware CSS using [data-theme="dark"] and [data-theme="light"] selectors. That fix was straightforward.
The second problem was subtler. The widget renders inline on the tool page (not in an iframe), and it has its own theme system with CSS custom properties. Claude’s initial implementation only checked for a ?theme= URL parameter or fell back to prefers-color-scheme. It never looked at the site’s data-theme attribute. So you could toggle the site to light mode, the entire page would flip, and the calculator widget would stay dark.
The fix was a MutationObserver that watches the HTML element for data-theme changes:
var siteTheme = document.documentElement.getAttribute('data-theme');
if (siteTheme) root.setAttribute('data-theme', siteTheme);
var obs = new MutationObserver(function() {
var t = document.documentElement.getAttribute('data-theme');
if (t) root.setAttribute('data-theme', t);
else root.removeAttribute('data-theme');
});
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
This pattern syncs instantly when the user clicks the theme toggle. It also turned out the same bug existed in the three older widgets (carry-on, bag fit, checked bag fees), so we retrofitted the fix into all four. Claude should have caught this during the initial build. The theme system is not new, and the data-theme attribute is visible in the site’s global CSS. But the widget’s self-contained design (its own CSS custom properties, its own theme logic) meant Claude treated it as isolated from the host page’s theme. In iframe mode, that is correct. In inline mode, it is not.
What went wrong overall
The commit was too big. The entire feature landed in a single 2,874-line commit: the widget component, the enrichment data, the full tool page, the embed page, the customizer, the theme fixes across four widgets, the index page cards. That is too much surface area to review in one pass, and it makes git bisect useless if something breaks later.
The reason it happened was velocity. The back-and-forth with Claude was fast enough that breaking for a commit felt like an interruption. The handoff document laid out seven scope items, and we knocked them out sequentially without stopping to checkpoint. That worked for shipping speed. It did not work for reviewability.
Where it is now
The calculator is live at vientapps.com/tools/connection-time with the full depth pass: sortable MCT comparison table, 13 scenario cards, customs-by-region panel, best and worst airports for connections, methodology section, and 14-question FAQ. The embeddable widget is also live with the same customization options as the other three widgets: light/dark/auto themes, accent color, corner radius, compact mode, and URL parameters to pre-select the airport and connection type.
What I would do differently
Commit after each scope item, not after all seven. The handoff document had clean boundaries: enrichment data, widget component, tool page sections, embed page, customizer, theme fixes. Each one was independently shippable. Batching them into one commit saved no time and made the code review harder. Claude and I both knew there were seven items. We should have committed seven times.
Establish the widget theme contract before building any more widgets. The MutationObserver pattern should have existed from the carry-on widget. Every widget we build inline will need to sync with data-theme. Retrofitting four widgets at once is a sign that this should be a shared utility or at least a documented convention, not something each widget reinvents.
Test the enrichment data against primary sources before building UI on top of it. Claude’s data was close enough that I did not catch any errors in my spot checks, but “close enough” is a dangerous bar for a tool people use to decide whether they will make their flight. I should have run a systematic verification pass against CBP wait time data and airport websites before the data went live, not after.
Frequently Asked Questions
How does the connection time calculator determine if a layover is long enough?
Where does the airport connection time data come from?
How does the embeddable widget keep its file size under 50 KB?
Why does the widget use a MutationObserver for theme syncing?
Senior Staff Engineer and Indie Developer
Caden Sorenson is a senior staff engineer with 15+ years of experience building iOS apps, web platforms, and developer tools. He holds a Computer Science degree from Utah State University and runs Vientapps, an indie studio based in Logan, Utah, where he ships small, focused tools and writes about every build in public.
Related posts
- I Built a Free Embeddable Carry-On Size Widget for Travel BlogsA free, zero-tracking carry-on size checker widget that any travel blog can embed in two lines of code. 75 airlines, auto-updating data, and full theme customization.
- Building a Checked Bag Fee Calculator Widget That Actually ComputesHow I built a framework-free fee calculator widget that computes overweight surcharges, estimates third bags, and sorts 75 airlines by total cost, all inside an iframe.
- I Let Claude Design My Homepage Hero and Shipped What It BuiltI gave Claude Design six 'decide for me' answers and it came back with a three-layer canvas flight animation. Two concepts, one conversation, and the whole thing is live on my homepage now.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.
Built as part of
View the project →