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.
Demo
London to New York
11h 35m · 1 stop- Thu 5 NovDepart from London (LHR)Heathrow · Terminal 2
- 2h 35mFlight time 2h 35m. TAP Air Portugal TP 1351 Economy · Airbus A320neo
- Thu 5 NovArrive at Lisbon (LIS)Humberto Delgado · Terminal 1
- 50m layover in Lisbon
- Short connection
- Thu 5 NovDepart from Lisbon (LIS)Terminal 1
- 8h 10mFlight time 8h 10m. TAP Air Portugal TP 207 Economy · Airbus A330-900
- Thu 5 NovArrive at New York (JFK)John F. Kennedy · Terminal 1
Copenhagen to Singapore
30h · 2 stops- Sat 12 DecDepart from Copenhagen (CPH)Terminal 3
- 1h 55mFlight time 1h 55m. Nordic Wings NW 512
- Sat 12 DecArrive at London (LHR)Terminal 2
- 11h 30m layover in London
- Long layover
- Overnight
- Change terminals: T2 to T4
- Sun 13 DecDepart from London (LHR)Terminal 4
- 6h 55mFlight time 6h 55m. Desert Air DA 4
- Sun 13 DecArrive at Dubai (DXB)Terminal 3
- 2h 5m layover in Dubai
- Change airports: DXB to DWC
- Self-transfer: collect and check in your bags again
- Sun 13 DecDepart from Dubai (DWC)Al Maktoum
- 7h 35mFlight time 7h 35m. Lion Wings LW 33
- 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.qwemain.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>}Related
TripItineraryA trip day by day: flights, trains, stays, activities and meals in order of time, with booking references, status, today's day marked and the trip counted up.9 views · 10 downloadsBaggageSelectorChecked bags for each passenger with counters, what the first, second and third bag cost, the cabin allowance, the total and the saving against the airport.9 views · 8 downloadsFareCalendarA month of the cheapest fare each day: prices coloured cheap to dear, the lowest day marked, months with fares to move between, and a day to pick.8 views · 8 downloadsFareOptionsFare families side by side, Basic to Flex: the price a person and over the cheapest, what each includes, costs extra or leaves out, and the total for everyone.8 views · 12 downloadsFlightResultA flight in search results: departure and arrival times, the time in the air, stops and layovers, cabin and bags, and the fare with Select.9 views · 9 downloadsFlightSearchA flight search form: return or one way, from and to with a swap button and airport suggestions, dates, travellers and cabin, checked before it searches.8 views · 7 downloads
Thread
Thread 0 posts
On LayoverTimeline: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.