Data Pipeline — Technical Overview

How flight price data is collected, transformed, modelled, and served to the dashboard.

Architecture

A lightweight, four-layer pipeline: an ingestion script pulls from the Duffel API, a relational store normalizes the data, a prediction service trains models nightly, and a thin API layer feeds the frontend.

Duffel API
→
Ingestion Script
Python + httpx
→
PostgreSQL
Price time-series
→
Prediction Service
XGBoost / Linear
→
PHP Proxy
Vanilla JS + Chart.js

Data Source

A single public API source for flight pricing data.

SourceWhatHowFrequency
Duffel APIFlight prices, availability, departure datesREST GET /v1/searchDaily

Ingestion

Idempotent upserts with composite unique keys — re-running the script never creates duplicates.

import httpx, os, pandas as pd
from datetime import date, timedelta

def fetch_flight_prices(api_key, route, depart_date):
    """Pull flight prices from the flight search API."""
    base_url = os.environ["FLIGHT_API_URL"]
    resp = httpx.get(
        base_url + "/v1/search",
        params={"from_iata_code": route.origin, "to_iata_code": route.dest},
        headers={"Authorization": f"Bearer {api_key}"},
        timeout=30
    )
    resp.raise_for_status()
    results = resp.json()["results"]
    rows = []
    for r in results:
        for seg in r["segments"]:
            if seg["type"] == "flight" and seg.get("price"):
                rows.append({
                    "route_code": f"{route.origin}-{route.dest}",
                    "observation_date": date.today(),
                    "departure_date": depart_date,
                    "price_cents": seg["price"]["value"],
                    "currency": seg["price"]["currency"],
                    "category": classify_route(route),
                    "source": "duffel",
                })
    return pd.DataFrame(rows)

def upsert_prices(df, conn):
    """Idempotent upsert — UNIQUE(route_code, observation_date, departure_date, source)."""
    df.to_sql("ft_price_observations", conn, if_exists="append", index=False,
              method="multi", chunksize=50)
    # ON CONFLICT DO UPDATE at DB level

Data Store

A relational store normalized for time-series queries and fast filtering by route / category / date.

TablePurposeKey Constraints
ft_routesRoute definitions and category mappingUNIQUE(route_code)
ft_price_observationsRaw price observations (time series)UNIQUE(route_code, obs_date, dep_date, source)
ft_cohort_statsAggregated cohort statistics (NEAR/MID/FAR)UNIQUE(route_code, cohort, dep_date)
ft_model_predictionsStored model forecastsUNIQUE(route_code, pred_date, horizon, model)
ft_model_metricsValidation metrics (MAE, RMSE, MAPE)UNIQUE(pred_date, model, horizon)

Prediction Models

Two models trained nightly at 02:00 against the accumulated price history. Forecasts cover a 3–90 day horizon before departure.

ModelFeaturesTarget
XGBoostDays-to-departure, category, weekday, rolling mean/var (7d, 30d)Median price at T+n
LinearDays-to-departure, category, rolling mean (7d)Median price at T+n

API Layer

Thin endpoints — each is a single query wrapped in JSON. No business logic; all computation lives in the prediction service or the frontend.

# FastAPI endpoint (simplified)
@app.get("/api/free/trends")
def get_trends(currency: str = "EUR"):
    """Return price trend index per category (chain-indexed, start=100)."""
    conn = get_conn()
    cur = conn.cursor()
    cur.execute("""
        SELECT category, observation_date,
               percentile_cont(0.5) WITHIN GROUP (ORDER BY price) AS median
        FROM ft_price_observations
        WHERE observation_date >= now() - interval '30 days'
        GROUP BY category, observation_date
        ORDER BY category, observation_date
    """)
    rows = cur.fetchall()
    conn.close()
    return {"ok": True, "trends": build_chain_index(rows, currency)}

Frontend

Vanilla JavaScript with Chart.js 4.x. No build step, no framework — each section is a standalone module that fetches from the API and renders into its container.

// index.html — data loading + chart rendering
var API_BASE = window.location.origin + '/api/flight-tracker';
var ftLang = localStorage.getItem("seb-lang") || "en";

function loadData() {
  var p1 = fetch(API_BASE + '?view=free¤cy=' + currentCurrency);
  var p2 = fetch(API_BASE + '?view=forecast¤cy=' + currentCurrency);
  Promise.all([p1, p2]).then(function(results) {
    allData = results[0];
    forecastData = results[1];
    render();
  });
}

function renderTrendChart(trends) {
  var ctx = document.getElementById('trend-chart');
  new Chart(ctx, {
    type: 'line',
    data: {
      labels: trends.dates.map(d => d.slice(5)),
      datasets: trends.series.map((s, i) => ({
        label: s.name,
        data: s.index,
        borderColor: COLORS[i],
        tension: 0.3,
        pointRadius: 2
      }))
    },
    options: { responsive: true }
  });
}

Transformations

What happens between the raw API response and the data store.

StageTransformation
Duffel JSONFlatten segment pricing; convert to minor units (cents); extract IATA codes as route identifier
Route classificationMap route to category (intercontinental / regional / budget EU) based on airport pairs
CurrencyConvert to EUR and USD using daily reference rates; store both
Cohort assignmentAssign each observation to NEAR (0–7d), MID (8–30d), or FAR (31d+) bucket by days-to-departure
All sourcesISO-8601 dates; NULL for missing values; source tag for provenance

Data Privacy

All publicly exposed data is anonymized. No passenger data, no airline identifiers beyond IATA codes, no individual booking records. Prices are aggregated per route and date — never per traveller.