Skip to content

pamelayin/flight-tracker

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

23 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SkyPath

Flight connection search engine — Go backend + React (Vite) frontend.

Prerequisites

  • Docker and Docker Compose (bundled with Docker Desktop)

That's it — no local Go or Node install required, everything runs in containers.

Running the app

From the project root:

docker compose up --build

This builds and starts both services:

  • Backendhttp://localhost:8080 (Go API, loads flights.json)
  • Frontendhttp://localhost:5173 (React app, calls the backend at localhost:8080)

Open http://localhost:5173 in a browser once both containers report as started.

Run in the background instead:

docker compose up --build -d

Stop everything:

docker compose down

Verifying it's working

curl http://localhost:8080/health
# {"status":"ok"}

Running tests

cd backend/app
go test ./...

No local Go install needed just to run the app (see "Running the app" above) — this is only for running the backend's test suite directly. All tests live in backend/app; backend/main.go (package main) has no tests of its own.

API reference

All three endpoints are unauthenticated GET requests returning JSON.

GET /search

Param Format Required
origin 3-letter airport code, e.g. JFK yes
destination 3-letter airport code, e.g. LAX yes
date YYYY-MM-DD, interpreted in the origin airport's local timezone yes

Returns 200 with every valid itinerary (direct, 1-stop, 2-stop) for that route and date, sorted by total travel time ascending:

{
  "origin": "JFK",
  "destination": "LAX",
  "date": "2024-03-15",
  "itineraries": [
    {
      "segments": [
        {
          "flightNumber": "SP101",
          "airline": "SkyPath Airways",
          "origin": "JFK",
          "destination": "LAX",
          "departureTime": "2024-03-15T08:30:00",
          "arrivalTime": "2024-03-15T11:45:00",
          "price": 299,
          "aircraft": "A320",
          "durationMinutes": 375
        }
      ],
      "totalMinutes": 375,
      "totalPrice": 299,
      "stops": 0
    }
  ]
}

segments[n].layoverMinutes is present on every segment after the first (the gap before that segment, at its own origin).

Returns 400 with {"error": "<message>"} for a missing/malformed param, an unknown airport code, or origin == destination.

GET /airports

No params. Returns 200 with every airport in the dataset, sorted by code:

[
  { "code": "AMS", "name": "Amsterdam Schiphol", "city": "Amsterdam", "country": "NL", "timezone": "Europe/Amsterdam" }
]

GET /health

No params. Returns 200 with {"status": "ok"} if the server is up (implies the dataset loaded successfully at startup, since the process would have exited otherwise).

Configuration

Environment variables read by the backend (all optional, with defaults suitable for local Docker use):

Variable Default Purpose
FLIGHTS_DATA_PATH flights.json Path to the dataset file to load at startup
PORT 8080 Port the backend HTTP server listens on
ALLOWED_ORIGIN http://localhost:5173 The single origin allowed via CORS (see "CORS is locked to a single configurable origin" below)

The frontend reads one build-time variable: VITE_API_URL (default http://localhost:8080), the backend URL it calls.

Assumptions & design decisions

The dataset and spec left some behavior ambiguous. Decisions made, and why:

  • Price accepts a JSON number or a quoted string (e.g. 299 or "299.00") — the dataset mixes both encodings. A price is rejected (and that flight skipped, not fatal to the whole load) if it has more than 2 decimal places or doesn't parse as a number at all. A price with fewer than 2 decimals, or none (e.g. 99), is accepted as valid — we only reject extra precision that wouldn't correspond to real currency, not the presence/absence of .00.
  • Malformed individual flights are skipped, not fatal. A flight with an unknown airport code, bad timestamp format, negative/NaN price, or missing required field is logged as a warning and excluded from the dataset — the rest of the load proceeds. Malformed airports, by contrast, are fatal on load, since airports are foundational reference data every flight and search depends on.
  • The search date only bounds the first leg. It's interpreted as a calendar day in the origin airport's local timezone, and only the first flight's departure is required to fall within that day. Connecting flights (2nd/3rd leg) are free to depart or arrive on a later calendar day — a red-eye or long layover can push a connection into the next day, and that's expected, not a bug.
  • origin == destination is a 400 validation error, not an empty 200 result — it's a malformed query, distinct from a legitimate "no route found."
  • A search with zero matching itineraries returns 200 with an empty itineraries array, not an error — "no route exists between these airports on this date" is a valid, well-formed outcome.
  • Max itinerary length is 3 legs (2 stops), per the spec, but implemented as a single constant (maxLegs in backend/app/search.go) rather than a hardcoded depth in the search logic, so it can be changed later without restructuring the algorithm.
  • Domestic vs. international is evaluated per-connection, using the airport country fields of the arriving flight's origin, the arriving flight's destination, and the departing flight's destination. All three matching = domestic; any mismatch = international. This means a connection is international as soon as either leg alone crosses a border (e.g. JFK→LHR→CDG is international because JFK→LHR already crosses countries, independent of LHR→CDG).
  • Layover minimums/maximums are measured as real elapsed time between the parsed arrival and next departure instants (each in its own airport's timezone), not by comparing local-time strings directly — this is what keeps the math correct when a connection or itinerary crosses timezones or the international date line.
  • The 6-hour maximum layover applies per-connection, not as a sum across the itinerary. The spec lists it in the same table as the per-connection minimums, so it's treated the same way: each individual layover must be ≤6h, but a 2-stop itinerary can have e.g. two separate 5h59m layovers (~12h of total layover time) and still be valid. There is no accumulated-layover check.
  • "No airport-change during layover" is enforced structurally, not via an explicit check: a candidate connecting flight is only ever looked up by using the previous flight's destination as the next flight's origin, so a JFK arrival can never be chained to an LGA departure in the first place.
  • Airport codes must be exactly 3 uppercase letters (^[A-Z]{3}$) — lowercase, or codes of a different length, are rejected as invalid input rather than normalized/uppercased.
  • Sort order is total travel time ascending, tie-broken by fewer stops. The spec only specifies sorting by total travel time; when two itineraries have identical TotalMinutes, the one with fewer stops is placed first (a stable sort preserves discovery order for any remaining ties, e.g. equal minutes and equal stops).

Project structure

skypath/
├── backend/             # Go API service
│   ├── Dockerfile
│   ├── go.mod
│   ├── main.go          # wiring: load dataset, build SearchService, start HTTP server
│   └── app/
│       ├── dataset.go   # flights.json loading + per-record validation
│       ├── search.go    # FlightIndex, DFS connection search, itinerary building
│       ├── handler.go   # HTTP handlers (/search, /airports, /health) + request parsing
│       ├── errors.go    # typed validation errors -> HTTP status mapping
│       ├── dataset_types.go  # dataset types (Flight, Airport, Price, FlightDataset)
│       └── *_test.go
├── frontend/             # React (Vite) app
│   ├── Dockerfile
│   ├── index.html
│   ├── package.json
│   ├── vite.config.js
│   └── src/
├── docker-compose.yml
└── flights.json          # dataset, mounted read-only into the backend container

Architecture decisions

  • Go for the backend — a language well-suited to building microservices, and one I'm familiar with. The data here is dense with numeric/time constraints (min/max layover, domestic vs. international, timezone math), so a strongly typed language pays off in catching mistakes in that logic early and keeping it easy to read and test. The standard library's net/http and time packages are enough; no router or ORM dependency was needed.
  • DFS over the flight index, not a generic graph library. Flights are grouped into a FlightIndex (backend/app/search.go:28) keyed by origin airport, with each airport's flights pre-sorted by departure time. A search is a depth-first walk from origin, capped at maxLegs (backend/app/search.go:9), where each recursive step only considers flights from the current airport — so "no airport changes during a layover" falls out of the data structure itself rather than needing an explicit check (see README "Assumptions" below).
  • Binary search to prune each step's candidate flights, instead of scanning every flight from an airport. Since each airport's flights are sorted by departure time, sort.Search (backend/app/search.go:173) jumps straight to the earliest flight that could satisfy the current layover window, and the loop breaks as soon as a candidate's departure exceeds the window's upper bound. This keeps each DFS step near O(log n + k) instead of O(n), where k is the small number of flights actually in the layover window.
  • Timestamps are parsed once, at index-build time, not per-request. indexedFlight (backend/app/search.go:20) stores the already-parsed departure/arrival time.Time alongside the raw Flight, so a search never re-parses the same timestamp string across multiple candidate paths.
  • Validation happens once at load time, not at query time. dataset.go validates every airport and flight when flights.json is loaded (see README "Malformed individual flights are skipped" below), so search.go can assume clean data and skip defensive parsing/error-handling in the hot path — comments in search.go:30-33 and :57-60 call this out explicitly at the two spots that would otherwise look like missing error handling.
  • React + Vite for the frontend, chosen for fast local iteration (no build step to babysit) and because the UI here is a single form + results view — no routing or heavy state management library was justified.
  • Frontend owns sorting/filtering client-side, not the API. /search returns all valid itineraries for a route/date; App.jsx's sortItineraries/stopFilter re-derive the displayed order and subset from that one response. This keeps the API surface small (one query = one deterministic result set) and makes the UI controls (multi-column sort, stop filter) instant since they don't round-trip to the server.
  • Route registration lives in app.NewRouter (backend/app/handler.go:15), next to the handlers it wires up, rather than inline in main.go. main.go is left with a single responsibility — read config, load the dataset, build the service, start the server — instead of mixing process wiring with HTTP routing decisions.
  • CORS is locked to a single configurable origin, not a wildcard. corsMiddleware (backend/main.go) sets Access-Control-Allow-Origin to the value of an ALLOWED_ORIGIN env var (defaulting to the frontend's own http://localhost:5173), since the API is only ever called by the SkyPath frontend and has no reason to accept cross-origin reads from arbitrary sites.

Tradeoffs considered

  • DFS with per-step pruning vs. precomputed all-pairs itineraries. Precomputing every possible route ahead of time would make individual searches faster but requires recomputing (or invalidating) the precomputed set whenever the dataset changes, and blows up memory for a ~260-flight dataset that doesn't need it. DFS-per-request is simpler and fast enough at this scale; it would need revisiting (e.g. caching, or an actual routing index) if the dataset grew by orders of magnitude.
  • In-memory dataset vs. a real database. Loading flights.json into memory once at startup (main.go) is simplest for a fixed, read-only dataset of this size. A database would help if the dataset needed to be updated at runtime or queried in ways beyond origin/destination/date, but it's unjustified complexity here.
  • Total-layover cap vs. per-connection only. The spec's 6-hour max layover rule is implemented per-connection (see README "Assumptions" below) rather than as a sum across a multi-stop itinerary. This was a judgment call under ambiguous wording — the alternative (capping cumulative layover time) is a reasonable product decision but isn't what the spec's Connection Rules table states, so it wasn't implemented as a hard rule.
  • Server-side vs. client-side sort/filter. Doing this in the frontend (see above) trades a slightly larger response payload (all itineraries, always) for simpler API semantics and instant UI interaction. This would need to move server-side if result sets ever got large enough that sending "everything" became expensive.
  • FlightIndex grouped-and-sorted-by-origin vs. a flat slice. For ~260 flights, a linear scan per search would work fine — the index (backend/app/search.go:28) is overkill for this dataset's size. It was built this way anyway so the shape is right for a larger dataset: grouping by origin turns "flights from this airport" into a map lookup, and pre-sorting by departure lets each DFS step binary-search straight to the layover window (see "Binary search to prune" above) instead of scanning. The cost today is a bit of upfront index-building at startup for a win that only pays off once the dataset is much bigger.

What I'd improve with more time

  • A total-layover-time toggle or display, since it came up as a point of ambiguity — even if the hard rule stays per-connection, surfacing total layover time in the UI would help users self-select against itineraries with a lot of cumulative dead time.
  • Caching or incremental index updates if flights.json were ever replaced by a live feed — right now the index is built once at startup and never refreshed.
  • More end-to-end/integration tests exercising the full HTTP flow (request in, JSON out) for each of the six spec test cases, rather than relying on a mix of unit tests plus manual curl verification.
  • Pagination or result capping for routes with very large itinerary counts, so the response payload and client-side sort/filter stay fast as the dataset grows.
  • Airport-code autocomplete debounce/highlighting polish in the frontend, and a more thorough loading/error state for the /airports fetch on initial page load (it currently fails silently to an empty list).
  • Surface domestic vs. international per connection. The backend already computes this (isDomesticConnection, backend/app/search.go:144) to pick the right minimum layover, but it isn't exposed in the API response or shown in the UI. Options worth exploring: label each layover as domestic/international in the results so users understand why minimums differ, and/or add a filter to restrict results to domestic-only connections.
  • Rate limiting on the API. There's currently no protection against a client hammering /search (which is the most expensive endpoint, doing a bounded DFS per request) — worth adding before this ran anywhere beyond local Docker.
  • Mobile-responsive layout. App.css has no @media breakpoints today; the search form and results cards are laid out for desktop width only.
  • A visible loading indicator, not just the "Searching…" text state — a progress bar or spinner would read better during a slow search.
  • More search toggles, e.g. round-trip vs. one-way, and a max-stops toggle at search time (today "stops" is only a post-search client-side filter over what /search already returned, not a param that changes the search itself).
  • Metrics to track traffic — request counts, latency, and error rates per endpoint (e.g. via Prometheus) — there's currently no visibility into how the API is being used or performing beyond the request logs.

About

SkyPath

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors