Where Is Everyone Flying From? Building TourChamp's Travel Board
A summer of TourChamp work on flight booking for touring crews: grouping a roster by departure airport, a flight search that found nothing because of midnight, and swapping 700 lines of passport regex for one Claude tool call.
Most of what I shipped on TourChamp over the summer was aimed at one person's job: booking flights for a touring crew. Nobody is doing that from a single spreadsheet column labelled "home airport". People live in two cities. Someone's already in Europe when the tour starts. A red-eye leaves the day before the deadline and lands in time. Here are the three pieces that taught me the most, plus one small security feature.
One question: where is this person flying from?
The booker's request was simple. Show a tour's roster grouped by departure airport, so she can book everyone out of LHR, then everyone out of LAX, and so on.
The first version put a nullable departure_airport column on crew_member_tour_legs, the per-crew, per-tour join table. It's blank by default, which means "use their home airport". That matters because it keeps the per-tour answer separate from the permanent one. Someone starting a tour from Lisbon doesn't live in Lisbon, and I didn't want a booking tweak to overwrite their profile.
A day later the design spec got a second pass, because one home airport wasn't enough. Some crew really do have two bases. So home airports moved into their own table (crew_member_home_airports, IATA codes checked against the same airports list the autocomplete uses). The grouping rule became:
def grouping_airports
return [ departure_airport.strip.upcase ] if departure_airport.present?
codes = crew_member.home_airport_codes
codes.presence || [ nil ]
end
With no override, a crew member shows up in every group for their home airports. That's deliberate: until someone decides where they're flying from, each of those airports is a real option. Setting an override collapses them into one group. If the override isn't one of their home airports, the row is flagged "Alternate — not home" so it's clear on sight. Anyone with no airport at all lands in a "No home airport set" bucket pinned to the bottom, which works as a to-do list.
The controller turns that into an ordered hash. Named airports sort alphabetically and the empty bucket goes last:
buckets = Hash.new { |h, k| h[k] = [] }
assignments.each do |a|
a.grouping_airports.each { |code| buckets[code || NONE_GROUP] << a }
end
Once grouping worked, the rest of the board followed. There's a booking status per person (no bookings, awaiting approval, confirmed) and any number of airline and reference pairs. A "Bookings completed X of Y" progress bar runs across the top. The tour selector defaults to the next upcoming tour that isn't fully booked. An info panel has everything a booker needs in one place: name as it appears on the passport, frequent flyer and Global Entry numbers, seat preference, phone and notes. Edits come back as Turbo Streams, so changing someone's override moves their row into the right group without reloading the page.
The best part was letting crew answer the question themselves. The verification email already asks crew to confirm their details before a tour. Now that page also asks "Which airport will you fly out from for this tour?". The field is pre-filled from their home airport and saves straight to the same per-tour override. The page also takes start-of-tour notes (arriving early, staying on, special requests), and those show up in an amber callout on the booker's row. That moves the information from a reply-all thread into the place where someone actually acts on it.
The flight search that found nothing
The board has a beta flight search that calls Google Flights through SerpApi. Each crew member has a derived query: their effective departure airport, the tour's arrival airport, and an "arrive by" deadline. The service searches the day before and the day of, merges the results, drops anything that lands too late and sorts by price.
The first real test was a transatlantic overnight route with an arrival deadline, and it returned zero flights.
The cause was in how the deadline was built. When a tour has no explicit arrival time, it's derived from the first show date minus production days:
(first_show_date - production_days.to_i).beginning_of_day
beginning_of_day is midnight. So "be there on September 30" meant "be there by 00:00 on September 30", and that rules out almost every flight that arrives on the 30th. Overnight flights that left the day before mostly landed in the morning, after midnight, so those got dropped too. The filter was doing exactly what it was told.
The fix is small, and I like it because it doesn't guess when it doesn't have to:
# A date-only required-arrival resolves to midnight (00:00); treat such a
# bare-date deadline as "arrive any time that day". An explicit time is
# respected as-is.
by = by.end_of_day if by == by.beginning_of_day
If someone enters a real time, like 14:00 for a production call, that's the deadline. If all we have is a date, the whole day counts. There's a test for each case.
Right after that I widened the search. Long-haul overnights often leave two days before the deadline, not one. The window moved into a small helper so it could be tested without hitting the network:
LOOKBACK_DAYS = 2
def candidate_dates(outbound_date)
anchor = Date.parse(outbound_date.to_s)
((anchor - LOOKBACK_DAYS)..anchor).map(&:iso8601)
end
The same search went from 0 options to about 16. Two other small decisions helped. Arrival times from SerpApi are plain local-time strings, so anything blank or unparseable gets excluded. If I can't prove a flight lands in time, it isn't offered. And the merge-and-filter step is a pure function (combine_arriving_by), so the tests cover the rules without mocking an HTTP client.
The lesson: a date pretending to be a time is a bug waiting to happen. Midnight is a real time, and every comparison against it means something specific.
Passport OCR: 709 lines out, one tool call in
TourChamp reads passport scans so nobody has to retype passport numbers. The original service sent images to Google Cloud Vision, got back a block of raw text, and then parsed it with a pile of regex. There was MRZ parsing, a "visual zone" parser, and a separate parser for bilingual passports with labels like Surname/Nom and Date of issue/Date de délivrance. It even had a pattern for a known OCR misread of "issue". It worked, mostly, but every new passport layout meant another branch.
What forced the change was the Google side. The service started failing with "Cloud Vision API has not been used in project..." The app already used the Claude API to process inbound email replies, including passport attachments, so I moved OCR onto the same pipeline.
The new version makes one structured tool-use call. It defines a JSON schema for the fields: document type, passport number, names, nationality, issuing country, authority, dates as ISO strings, gender, place of birth, a full transcription and a high, medium or low confidence rating. Then it forces the model to call that tool:
tools: [ { name: TOOL_NAME, description: "Record the data read from the document", input_schema: SCHEMA } ],
tool_choice: { type: "tool", name: TOOL_NAME },
The system prompt does the work the regex used to do, in plain language. Extract only what's printed and never guess. Cross-check the MRZ against the visual zone, and prefer the MRZ for the number and dates. Expand two-digit MRZ years sensibly. Use proper capitalization for names, not MRZ caps. If it isn't a passport or a crew data sheet, say so and leave the fields empty.
The diff was 143 lines added and 709 removed. The public interface didn't change: callers still get extracted_data, raw_text, confidence and is_crew_data_sheet. The parts that deal with the real world stayed too. PDFs are still converted to an image first (the first page, at 300 DPI, through ImageMagick). Every image is still normalized to a JPEG under 2000px on its longest side before it goes out. The text confidence levels map back onto the old numeric scale (95, 60 and 25), so nothing downstream needed changing. If a readable image comes back with no usable fields, confidence drops to zero.
When the "parser" is mostly a list of exceptions, a schema plus clear rules usually beats more regex. The rules got easier to read, too.
Magic links, done carefully
The smaller change was passwordless sign-in for admins, added next to the password form rather than replacing it, so a mail outage can't lock anyone out. The details are what make it safe:
- Only a SHA256 digest of the token is stored, so a copy of the database can't be replayed as a login.
- Links expire after 15 minutes and work once. The token is cleared before the session is created, and asking for a new link invalidates the old one.
- Known and unknown emails get the same response, so the form can't be used to find out who has an account. Rails' built-in
rate_limitcaps requests at 5 per 15 minutes per IP and email. Throttled requests get that same response too.
That commit also fixed something I hadn't noticed: both CI security gates had been failing on the main branch. Brakeman's --ensure-latest wanted a newer version, and bundler-audit had flagged an advisory in sqlite3. So nothing pushed had actually reached deploy. Bumping both gems unblocked the pipeline. Check that your deploy gate is green, not just that it exists.
What's next
The travel board is still marked beta, and the flight search only runs for admins in production. The next step is using both on a real tour and fixing whatever breaks first.