Travel

LayoverTimeline

A journey's flights and the layovers between them, with times, terminals and flags for short connections, overnight waits and changes of terminal or airport.

#travel#flights#timeline#schedule#no-js

Demo

Preview

London to New York

11h 35m · 1 stop
  1. Thu 5 NovDepart from London (LHR)Heathrow · Terminal 2
  2. 2h 35mFlight time 2h 35m. TAP Air Portugal TP 1351 Economy · Airbus A320neo
  3. Thu 5 NovArrive at Lisbon (LIS)Humberto Delgado · Terminal 1
  4. 50m layover in Lisbon
    • Short connection
  5. Thu 5 NovDepart from Lisbon (LIS)Terminal 1
  6. 8h 10mFlight time 8h 10m. TAP Air Portugal TP 207 Economy · Airbus A330-900
  7. Thu 5 NovArrive at New York (JFK)John F. Kennedy · Terminal 1

Copenhagen to Singapore

30h · 2 stops
  1. Sat 12 DecDepart from Copenhagen (CPH)Terminal 3
  2. 1h 55mFlight time 1h 55m. Nordic Wings NW 512
  3. Sat 12 DecArrive at London (LHR)Terminal 2
  4. 11h 30m layover in London
    • Long layover
    • Overnight
    • Change terminals: T2 to T4
  5. Sun 13 DecDepart from London (LHR)Terminal 4
  6. 6h 55mFlight time 6h 55m. Desert Air DA 4
  7. Sun 13 DecArrive at Dubai (DXB)Terminal 3
  8. 2h 5m layover in Dubai
    • Change airports: DXB to DWC
    • Self-transfer: collect and check in your bags again
  9. Sun 13 DecDepart from Dubai (DWC)Al Maktoum
  10. 7h 35mFlight time 7h 35m. Lion Wings LW 33
  11. Mon 14 DecArrive at Singapore (SIN)Terminal 1

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/layover_timeline.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/layover_timeline.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/layover_timeline.qwe
view LayoverTimelineDemo() =    <div class="demo-row" style="align-items: start">        <LayoverTimeline id="layover-timeline-demo-lis" legs={[            { from: { code: "LHR", city: "London", name: "Heathrow", time: "07:45", date: "2026-11-05", terminal: "2" },              to: { code: "LIS", city: "Lisbon", name: "Humberto Delgado", time: "10:20", date: "2026-11-05", terminal: "1" },              airline: "TAP Air Portugal", flight: "TP 1351", minutes: 155, aircraft: "Airbus A320neo", cabin: "Economy", layover: 50 },            { from: { code: "LIS", city: "Lisbon", time: "11:10", date: "2026-11-05", terminal: "1" },              to: { code: "JFK", city: "New York", name: "John F. Kennedy", time: "14:20", date: "2026-11-05", terminal: "1" },              airline: "TAP Air Portugal", flight: "TP 207", minutes: 490, aircraft: "Airbus A330-900", cabin: "Economy" }]}/>        <LayoverTimeline id="layover-timeline-demo-asia" title="Copenhagen to Singapore" legs={[            { from: { code: "CPH", city: "Copenhagen", time: "18:05", date: "2026-12-12", terminal: "3" },              to: { code: "LHR", city: "London", time: "19:00", date: "2026-12-12", terminal: "2" },              airline: "Nordic Wings", flight: "NW 512", minutes: 115, layover: 690, overnight: true },            { from: { code: "LHR", city: "London", time: "07:30", date: "2026-12-13", terminal: "4" },              to: { code: "DXB", city: "Dubai", time: "18:25", date: "2026-12-13", terminal: "3" },              airline: "Desert Air", flight: "DA 4", minutes: 415, layover: 125, self_transfer: true },            { from: { code: "DWC", city: "Dubai", name: "Al Maktoum", time: "20:30", date: "2026-12-13" },              to: { code: "SIN", city: "Singapore", time: "08:05", date: "2026-12-14", terminal: "1" },              airline: "Lion Wings", flight: "LW 33", minutes: 455 }]}/>    </div>

Source

ui/layover_timeline.qwe
// LayoverTimeline: a journey's legs and the waits between them, top to bottom, as a// booking's "flight details" show it: each airport with its time, date and terminal,// each flight with its airline, number, aircraft and time in the air, and each layover// with how long it is and what to watch for: a short or a long connection, a night at// the airport, a change of terminal or of airport, and a self-transfer (bags to// collect and check in again). The heading sums it up: from, to, the whole journey's// length and the number of stops.////   <LayoverTimeline legs={[//       { from: { code: "LHR", city: "London", time: "07:45", terminal: "2" },//         to: { code: "LIS", city: "Lisbon", time: "10:20", terminal: "1" },//         airline: "TAP Air Portugal", flight: "TP 1351", minutes: 155, aircraft: "Airbus A320neo",//         layover: 110 },//       { from: { code: "LIS", city: "Lisbon", time: "12:10", terminal: "1" },//         to: { code: "JFK", city: "New York", time: "15:20", terminal: "1" },//         airline: "TAP Air Portugal", flight: "TP 207", minutes: 490 }]}/>//// legs: the flights in order, each { from, to, airline?, flight?, minutes, aircraft?,// cabin?, layover?, self_transfer?, overnight? }. from, to: { code, city?, name? (the// airport's name), time, date? ("YYYY-MM-DD", shown "Thu 5 Nov"), terminal? }.// minutes: the flight's time in the air. layover: minutes waited after this leg for// the next one; self_transfer: true when the next flight is booked apart (bags to// collect again); overnight: true for a wait across the night. short: the least// connection that is not flagged as short (60 minutes); long: from how long a wait is// called long (360 minutes). total: the journey's minutes (by default the legs and// layovers added up). title: the heading (by default "London to New York"). id, class.import ui/theme.qwe use ui_clslet layover_timeline_css = css`.ui-layover { display: grid; gap: 14px; min-width: 0; max-width: 560px; padding: 18px 20px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);  background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); font-variant-numeric: tabular-nums; }.ui-layover-head { display: grid; gap: 2px; }.ui-layover .ui-layover-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-layover-sum { color: var(--ui-muted); font-size: 13px; }.ui-layover-list { margin: 0; padding: 0; list-style: none; }.ui-layover-point, .ui-layover-leg, .ui-layover-wait { position: relative; display: grid; grid-template-columns: 52px 18px minmax(0, 1fr); column-gap: 10px; }.ui-layover-at { padding-top: 1px; font-weight: 700; text-align: right; white-space: nowrap; }.ui-layover-day { display: block; color: var(--ui-muted); font-size: 11px; font-weight: 500; }.ui-layover-rail { position: relative; display: flex; justify-content: center; }.ui-layover-rail::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; border-left: 2px solid var(--ui-strong); transform: translateX(-1px); }.ui-layover-point:first-child .ui-layover-rail::before { top: 9px; }.ui-layover-point:last-child .ui-layover-rail::before { bottom: calc(100% - 9px); }.ui-layover-dot { position: relative; width: 12px; height: 12px; margin-top: 4px; border: 2px solid var(--ui-accent); border-radius: 50%; background: var(--ui-surface); }.ui-layover-point:first-child .ui-layover-dot, .ui-layover-point:last-child .ui-layover-dot { background: var(--ui-accent); }.ui-layover-place { display: grid; padding-bottom: 2px; }.ui-layover-place b { font-weight: 650; }.ui-layover-place span { color: var(--ui-muted); font-size: 12px; }.ui-layover-leg .ui-layover-body { display: flex; flex-wrap: wrap; gap: 2px 10px; margin: 6px 0; padding: 8px 12px; border-radius: var(--ui-radius-sm);  background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; }.ui-layover-leg .ui-layover-body b { color: var(--ui-fg); font-weight: 600; }.ui-layover-leg .ui-layover-at { align-self: center; color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-layover-wait .ui-layover-rail::before { border-left-style: dashed; border-left-color: var(--ui-border); }.ui-layover-wait .ui-layover-body { display: grid; gap: 6px; margin: 8px 0; padding: 10px 12px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-layover-wait-text { display: flex; align-items: center; gap: 6px; font-weight: 600; }.ui-layover-wait-text svg { flex: none; width: 16px; height: 16px; color: var(--ui-muted); }.ui-layover-flags { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-layover-flag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500;  background: var(--ui-warning-soft); color: var(--ui-fg); }.ui-layover-flag::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ui-warning); }.ui-layover-flag-danger { background: var(--ui-danger-soft); }.ui-layover-flag-danger::before { background: var(--ui-danger); }.ui-layover-flag-info { background: var(--ui-info-soft); }.ui-layover-flag-info::before { background: var(--ui-info); }.ui-layover-tight .ui-layover-wait-text { color: var(--ui-danger); }`let layover_timeline_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]let layover_timeline_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// Minutes as "7h 25m", "45m", "3h".fn layover_timeline_duration(m: int) -> str {    let h = floor(m / 60)    let r = m % 60    h == 0 ? `${r}m` : (r == 0 ? `${h}h` : `${h}h ${r}m`)}// "YYYY-MM-DD" as "Thu 5 Nov"; null for anything else.fn layover_timeline_date(d) -> str? {    if d == null || !date.valid(str(d), "date") { return null }    let p = date.parts(str(d))    `${layover_timeline_days[p.weekday - 1]} ${p.day} ${layover_timeline_months[p.month - 1]}`}// What to watch for in the wait after `leg`, before `next`: a list of { text, tone }.fn layover_timeline_flags(leg: map, next: map, short: int, long: int) -> list {    let m = leg.layover ?? 0    let airport = leg.to.code != next.from.code    let terminal = !airport && leg.to.terminal != null && next.from.terminal != null && str(leg.to.terminal) != str(next.from.terminal)    [        m > 0 && m < short && { text: "Short connection", tone: "danger" },        m >= long && { text: "Long layover", tone: "info" },        leg.overnight == true && { text: "Overnight", tone: "info" },        airport && { text: `Change airports: ${leg.to.code} to ${next.from.code}`, tone: "danger" },        terminal && { text: `Change terminals: T${leg.to.terminal} to T${next.from.terminal}`, tone: "warning" },        leg.self_transfer == true && { text: "Self-transfer: collect and check in your bags again", tone: "warning" },    ] | filter((f) => f != false)}view LayoverTimeline(legs: list, short: int = 60, long: int = 360, total: int? = null, title: str? = null,                     id: str? = null, class: str? = null) {    style global layover_timeline_css    let first = legs[0]    let last = legs[-1]    let stops = len(legs) - 1    let whole = total ?? sum(legs | enumerate | map((p) => p[1].minutes + (p[0] < stops ? p[1].layover ?? 0 : 0)))    let heading = title ?? `${first.from.city ?? first.from.code} to ${last.to.city ?? last.to.code}`    let summary = `${layover_timeline_duration(whole)} · ${stops == 0 ? "Nonstop" : (stops == 1 ? "1 stop" : `${stops} stops`)}`    let base = id ?? `layover-timeline-${lower(first.from.code)}-${lower(last.to.code)}`    let point = (p: map, kind: str) => {        let day = layover_timeline_date(p.date)        <li.ui-layover-point data-kind={kind}>            <span.ui-layover-at><time>{p.time}</time>{day != null && [" ", <span.ui-layover-day>{day}</span>]}</span>            <span.ui-layover-rail aria-hidden="true"><span.ui-layover-dot></span></span>            <span.ui-layover-place>                <b><span.ui-sr>{kind == "depart" ? "Depart from " : "Arrive at "}</span>{p.city ?? p.code} ({p.code})</b>                <span>{join([p.name, p.terminal != null ? `Terminal ${p.terminal}` : null] | filter((x) => x != null), " · ")}</span>            </span>        </li>    }    <section class={ui_cls(["ui-layover", class])} id={id} aria-labelledby={`${base}-title`}>        <div.ui-layover-head>            <h3.ui-layover-title id={`${base}-title`}>{heading}</h3>            <span.ui-layover-sum>{summary}</span>        </div>        <ol.ui-layover-list>            {for p in enumerate(legs) => {                let i = p[0]                let leg = p[1]                let next = i < stops ? legs[i + 1] : null                let flags = next != null ? layover_timeline_flags(leg, next, short, long) : []                let tight = next != null && (leg.layover ?? 0) > 0 && (leg.layover ?? 0) < short                [                    point(leg.from, "depart"),                    <li.ui-layover-leg data-leg={i + 1}>                        <span.ui-layover-at>{layover_timeline_duration(leg.minutes)}</span>                        <span.ui-layover-rail aria-hidden="true"></span>                        <span.ui-layover-body>                            <span.ui-sr>Flight time {layover_timeline_duration(leg.minutes)}. </span>                            {(leg.airline != null || leg.flight != null) && <b>{join([leg.airline, leg.flight] | filter((x) => x != null), " ")}</b>}                            {(leg.cabin != null || leg.aircraft != null) && [" ", <span>{join([leg.cabin, leg.aircraft] | filter((x) => x != null), " · ")}</span>]}                        </span>                    </li>,                    point(leg.to, "arrive"),                    next != null && <li class={ui_cls(["ui-layover-wait", tight && "ui-layover-tight"])} data-minutes={leg.layover}>                        <span.ui-layover-at></span>                        <span.ui-layover-rail aria-hidden="true"></span>                        <div.ui-layover-body>                            <span.ui-layover-wait-text>                                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>                                {leg.layover != null ? `${layover_timeline_duration(leg.layover)} layover` : "Layover"} in {leg.to.city ?? leg.to.code}                            </span>                            {len(flags) > 0 && <ul.ui-layover-flags>                                {for f in flags => <li class={ui_cls(["ui-layover-flag", f.tone != "warning" && `ui-layover-flag-${f.tone}`])}>{f.text}</li>}                            </ul>}                        </div>                    </li>,                ]            }}        </ol>    </section>}
Thread
910Useful? Like it, or add to its thread below.

Thread 0 posts

On LayoverTimeline: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.