TripTimeline
The legs of a trip through time zones, each in local time, with the next one highlighted, waits between legs, and when clocks and days change.
Demo
Lisbon to Los Angeles, the long way
Next: Metro to the airport in 4 h 25 minTue 3 Nov, 18:30 in Rossio
One leg has no time that can be read.
Tuesday 3 November
- 3 h 35 min between
Wednesday 4 November
1 h 45 min betweenThursday 5 November
3 h 50 min betweenFriday 6 November
16 h 25 min betweenSaturday 7 November
Sunday 8 November
12 h 15 min betweenMonday 9 November
20 h 30 min between
Picked: nothing yet.
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/trip_timeline.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/trip_timeline.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/trip_timeline.qwe
let trip_timeline_demo_legs = [ { id: "t3", kind: "flight", title: "EK 318", from: "Dubai", to: "Tokyo", departs: "2026-11-05T02:50:00+04:00", arrives: "2026-11-05T17:35:00+09:00", ref: "Seat 31K" }, { id: "t1", kind: "train", title: "Metro to the airport", from: "Rossio", to: "Lisbon Airport", departs: "2026-11-03T18:30:00+00:00", arrives: "2026-11-03T19:05:00+00:00" }, { id: "t2", kind: "flight", title: "TP 1012", from: "Lisbon", to: "Dubai", departs: "2026-11-03T22:40:00+00:00", arrives: "2026-11-04T09:15:00+04:00", ref: "Seat 14A", note: "Bag through to Tokyo." }, { id: "s1", kind: "stay", title: "Airport hotel", from: "Dubai", to: "Dubai", departs: "2026-11-04T11:00:00+04:00", arrives: "2026-11-04T23:00:00+04:00" }, { id: "t4", kind: "ferry", title: "Water bus", from: "Tokyo Bay", departs: "2026-11-06T10:00:00+09:00" }, { id: "t5", kind: "flight", title: "NZ 90", from: "Tokyo", to: "Auckland", departs: "2026-11-07T18:00:00+09:00", arrives: "2026-11-08T08:45:00+13:00" }, { id: "t6", kind: "flight", title: "NZ 2", from: "Auckland", to: "Los Angeles", departs: "2026-11-08T21:00:00+13:00", arrives: "2026-11-08T12:30:00-08:00" }, { id: "bad", kind: "bus", title: "Lost its time", from: "Nowhere", departs: "tomorrow" }, { id: "odd", kind: "bus", title: "Arrives before it leaves", from: "Here", to: "There", departs: "2026-11-09T09:00:00-08:00", arrives: "2026-11-09T08:00:00-08:00" },]component TripTimelineDemoLive() { state time = "2026-11-03T14:05:00+00:00" state picked = "nothing yet" <div.demo-stack> <TripTimeline title="Lisbon to Los Angeles, the long way" legs={trip_timeline_demo_legs} now={time} tick={1} on_pick={(l) => { picked = l.title }}/> <div.demo-row> <button.trip-timeline-demo-onboard type="button" style="min-height: 44px" @click={() => { time = "2026-11-03T23:30:00+00:00" }}>On the first flight</button> <button.trip-timeline-demo-after type="button" style="min-height: 44px" @click={() => { time = "2026-11-20T10:00:00+00:00" }}>Home again</button> </div> <p.demo-note>Picked: <span class="trip-timeline-demo-picked">{picked}</span>.</p> </div>}view TripTimelineDemo() = <TripTimelineDemoLive/>Source
ui/trip_timeline.qwe
// TripTimeline: the legs of a trip, in order, through however many time zones, each in the// local time of where it happens: the flight leaves at 22:40 in Lisbon and lands at 06:15 in// Dubai, the next day, and takes what it really takes. It marks what is past, what is under way// and the next leg, which stands out and counts down by itself, and says what a person needs// between legs: the wait, a change of clocks, a change of day. A time carries its own offset,// so it reads the same on a phone in any zone, with no table of zones and no network.//// import ui/trip_timeline.qwe use TripTimeline//// <TripTimeline title="Lisbon to Tokyo" now="2026-11-03T14:05:00+00:00" legs={[// { id: 1, kind: "flight", title: "TP 1012", from: "Lisbon", to: "Dubai", ref: "Seat 14A",// departs: "2026-11-03T22:40:00+00:00", arrives: "2026-11-04T09:15:00+04:00" },// { id: 2, kind: "stay", title: "Airport hotel", from: "Dubai", departs: "2026-11-04T11:00:00+04:00" },// ]}/>//// legs: a list of { id, kind, title, from, to?, departs, arrives?, ref?, note? }. kind: "flight",// "train", "bus", "ferry", "car", "stay" or "walk" (anything else draws a dot). departs,// arrives: ISO date-times with the offset of where they happen, shown as written. They need not// come in order. A leg whose `departs` cannot be read is left out, and counted; an `arrives`// that cannot be read, or that comes before it left, is treated as not given. now: the moment,// the same form, so that server and browser write the same; left out, nothing is marked until// the page has started, from the phone's clock. tick: seconds between looks at the clock (30).// title: the trip's heading. on_pick(leg): a leg pressed, inside one of your components.//// The equal of scrolling to find what is next is "Go to next", which also moves focus there.//// trip_timeline_rows, trip_timeline_mark, trip_timeline_span and trip_timeline_shift are the// working.import ui/theme.qwe use ui_clslet trip_timeline_css = css`.ui-triptl { position: relative; container-type: inline-size; font: 15px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-triptl-in { display: grid; gap: 14px; }.ui-triptl-side { display: grid; gap: 8px; align-content: start; }.ui-triptl-title { margin: 0; font-size: 20px; font-weight: 700; overflow-wrap: anywhere; }.ui-triptl-next { margin: 0; padding: 12px 14px; border-radius: var(--ui-radius); background: var(--ui-accent-soft); color: var(--ui-fg); font-weight: 600;}.ui-triptl-next span { display: block; color: var(--ui-muted); font-size: 13px; font-weight: 400; }.ui-triptl-go { box-sizing: border-box; justify-self: start; min-width: 44px; min-height: 44px; padding: 0 16px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-triptl-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-triptl-list { margin: 0; padding: 0; list-style: none; }.ui-triptl-day { margin: 16px 0 6px; color: var(--ui-muted); font-size: 13px; font-weight: 600; letter-spacing: .02em; }.ui-triptl-item:first-child .ui-triptl-day { margin-top: 0; }.ui-triptl-leg { box-sizing: border-box; display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 2px 10px; width: 100%; min-height: 72px; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: inherit; font: inherit; text-align: left; cursor: pointer; scroll-margin: 12px; -webkit-tap-highlight-color: transparent;}.ui-triptl-leg:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-triptl-leg[data-mark="past"] { opacity: .62; }.ui-triptl-leg[data-mark="now"] { border-color: var(--ui-success); box-shadow: inset 4px 0 0 var(--ui-success); }.ui-triptl-leg[data-mark="next"] { border-color: var(--ui-accent); box-shadow: inset 4px 0 0 var(--ui-accent), 0 0 0 1px var(--ui-accent); }.ui-triptl-icon { grid-row: 1 / span 3; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: var(--ui-subtle); color: var(--ui-fg); }.ui-triptl-icon svg { width: 20px; height: 20px; }.ui-triptl-name { font-weight: 600; overflow-wrap: anywhere; }.ui-triptl-times { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 8px; font-variant-numeric: tabular-nums; }.ui-triptl-times b { font-size: 19px; font-weight: 700; }.ui-triptl-where { color: var(--ui-muted); font-size: 13px; overflow-wrap: anywhere; }.ui-triptl-off { color: var(--ui-muted); font-size: 12px; }.ui-triptl-more { grid-column: 2; color: var(--ui-muted); font-size: 13px; overflow-wrap: anywhere; }.ui-triptl-gap { display: flex; flex-wrap: wrap; gap: 0 10px; margin: 4px 0 4px 17px; padding: 6px 0 6px 18px; border-left: 2px dashed var(--ui-strong); color: var(--ui-muted); font-size: 13px; }.ui-triptl-gap:empty { display: none; }.ui-triptl-shift { color: var(--ui-fg); font-weight: 600; }.ui-triptl-empty { padding: 20px 8px; color: var(--ui-muted); text-align: center; }/* With the room, what is next stays in sight beside the whole trip. */@container (min-width: 620px) { .ui-triptl-in { grid-template-columns: 250px minmax(0, 1fr); align-items: start; gap: 24px; } .ui-triptl-side { position: sticky; top: 12px; }}@media (hover: hover) { .ui-triptl-leg:hover { border-color: var(--ui-strong); } }`fn trip_timeline_clock() = now()fn trip_timeline_time(s) = type_of(s) == "str" && date.valid(s, "date-time")// The offset as minutes ahead of UTC: "+05:30" is 330.fn trip_timeline_minutes(off) { if off == "Z" { return 0 } let sign = slice(off, 0, 1) == "-" ? -1 : 1 sign * ((int(slice(off, 1, 3)) ?? 0) * 60 + (int(slice(off, 4, 6)) ?? 0))}// How a person would say an offset: UTC, UTC+1, UTC-3:30.fn trip_timeline_utc(off) { let m = trip_timeline_minutes(off) if m == 0 { return "UTC" } let rest = abs(m) % 60 `UTC${m < 0 ? "-" : "+"}${floor(abs(m) / 60)}${rest == 0 ? "" : `:${rest < 10 ? "0" : ""}${rest}`}`}// The calendar date a date-time was written in, which is the local one.fn trip_timeline_day(t) = slice(t, 0, 10)// 1 h 35 min, 45 min, 3 days 2 h. Whole minutes.fn trip_timeline_span(ms) { let mins = round(ms / 60000) if mins < 1 { return "under 1 min" } let days = floor(mins / 1440) let hours = floor((mins % 1440) / 60) let rest = mins % 60 if days > 0 { return hours > 0 ? `${days} ${days == 1 ? "day" : "days"} ${hours} h` : `${days} ${days == 1 ? "day" : "days"}` } if hours > 0 { return rest > 0 ? `${hours} h ${rest} min` : `${hours} h` } `${rest} min`}// What changing from one offset to another does to a person's watch, or "".fn trip_timeline_shift(from_off, to_off) { let m = trip_timeline_minutes(to_off) - trip_timeline_minutes(from_off) if m == 0 { return "" } `Clocks go ${m > 0 ? "forward" : "back"} ${trip_timeline_span(abs(m) * 60000)}`}// The local day an arrival falls on, against the day it left: "", "next day", "+2 days",// "previous day" (crossing the date line eastward does that).fn trip_timeline_day_change(from_t, to_t) { let n = date.days_between(trip_timeline_day(from_t), trip_timeline_day(to_t)) if n == 0 { return "" } if n == 1 { return "next day" } if n == -1 { return "previous day" } `${n > 0 ? "+" : ""}${n} days`}// The legs that can be read, in the order they really happen, whatever their offsets, with// what each one needs worked out once.fn trip_timeline_rows(legs) { let good = (type_of(legs) == "list" ? legs : []) | filter((l) => type_of(l) == "map" && trip_timeline_time(l.departs)) let made = good | enumerate() | map((pair) => { let l = pair[1] let dep = date.ms(l.departs) let arr_ok = trip_timeline_time(l.arrives) && date.ms(l.arrives) >= dep { id: l.id ?? pair[0], leg: l, dep: dep, arr: arr_ok ? date.ms(l.arrives) : null, arrives: arr_ok ? l.arrives : null, dep_off: date.parts(l.departs).offset, arr_off: arr_ok ? date.parts(l.arrives).offset : null, order: pair[0] } }) made | sort_by((r) => r.dep)}// Each row's mark at `ms`: "past", "now" (under way), "next" or "later". One row at most is// "next": the first still to leave. A row with no end is under way only until the next// leg leaves, and a stay is never quite over until it is.fn trip_timeline_mark(rows, ms) { if ms == null { return rows | map((_r) => "later") } let n = len(rows) let first_ahead = index_of(rows | map((r) => r.dep > ms), true) for pair in enumerate(rows) => { let i = pair[0] let r = pair[1] let ends = r.arr ?? (i + 1 < n ? rows[i + 1].dep : r.dep) i == first_ahead ? "next" : (r.dep > ms ? "later" : (ends > ms ? "now" : "past")) }}// What stands between one leg and the next: the wait, by real time, and a change of clocks.fn trip_timeline_between(a, b) { let end = a.arr ?? a.dep let off = a.arr_off ?? a.dep_off { wait: a.arr != null && b.dep > end ? trip_timeline_span(b.dep - end) : "", shift: trip_timeline_shift(off, b.dep_off) }}// "in 8 h 35 min", or "left 5 min ago".fn trip_timeline_until(ms, now_ms) = ms >= now_ms ? `in ${trip_timeline_span(ms - now_ms)}` : `${trip_timeline_span(now_ms - ms)} ago`fn trip_timeline_icon(kind) = match kind { "flight" => "M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5Z" "train" => "M12 2c-4 0-8 .5-8 4v9.5A3.5 3.5 0 0 0 7.5 19L6 20.5v.5h12v-.5L16.5 19a3.5 3.5 0 0 0 3.5-3.5V6c0-3.5-4-4-8-4ZM7.5 17a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm9 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3ZM18 11H6V6h12v5Z" "bus" => "M4 16c0 .9.4 1.7 1 2.2V20a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h8v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1.8c.6-.5 1-1.3 1-2.2V6c0-3.5-3.6-4-8-4s-8 .5-8 4v10Zm3.5 1a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm9 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3ZM18 11H6V6h12v5Z" "ferry" => "M20 21c-1.4 0-2.8-.5-4-1.3a7 7 0 0 1-8 0C6.8 20.5 5.4 21 4 21H2v2h2c1.4 0 2.7-.3 4-1a8.8 8.8 0 0 0 8 0c1.3.7 2.6 1 4 1h2v-2h-2ZM3.9 19H4c1.6 0 3-.9 4-2 1 1.1 2.4 2 4 2s3-.9 4-2c1 1.1 2.4 2 4 2h.1l1.9-6.7a1 1 0 0 0-.7-1.2L20 10.6V6a2 2 0 0 0-2-2h-3V1H9v3H6a2 2 0 0 0-2 2v4.6l-1.3.5a1 1 0 0 0-.7 1.2L3.9 19ZM6 6h12v4l-6-2-6 2V6Z" "car" => "M19 5H5L3 11v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8l-2-6ZM6.5 15a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm11 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3ZM5 10l1.5-3.5h11L19 10H5Z" "stay" => "M7 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm12-6h-8v7H3V5H1v15h2v-3h18v3h2v-9a4 4 0 0 0-4-4Z" "walk" => "M13.5 5.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM9.8 8.9 7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3A7.3 7.3 0 0 0 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6a2 2 0 0 0-2.5-.8L6 8.3V13h2V9.6l1.8-.7Z" _ => "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z"}fn trip_timeline_offset() { let off = 0 - js_new("Date").getTimezoneOffset() let hh = floor(abs(off) / 60) `${off < 0 ? "-" : "+"}${hh < 10 ? "0" : ""}${hh}:${abs(off) % 60 < 10 ? "0" : ""}${abs(off) % 60}`}view TripTimelineTimes(label: str, t: str, place, note: str = "") = <span.ui-triptl-times> <b>{date.format(t, "HH:mm")}</b> <span.ui-triptl-where>{label} {place ?? ""}</span> <span.ui-triptl-off>{trip_timeline_utc(date.parts(t).offset)}{note != "" ? `, ${note}` : ""}</span> </span>component TripTimeline(legs = [], now: str? = null, tick: num = 30, title: str = "Your trip", on_pick = null, class: str? = null) { style global trip_timeline_css state base = trip_timeline_time(now) ? date.ms(now) : null state seen_now = now state since = 0 state moved_on = 0 state box = null if now != seen_now { seen_now = now if trip_timeline_time(now) { base = date.ms(now) since = trip_timeline_clock() moved_on = 0 } } if tick > 0 && base != null { every(round(tick * 1000), () => { moved_on = trip_timeline_clock() - since }) } let rows = trip_timeline_rows(legs) let at_ms = base == null ? null : base + moved_on let marks = trip_timeline_mark(rows, at_ms) let count = len(rows) let next_i = index_of(marks, "next") let now_i = index_of(marks, "now") let aim_i = next_i ?? now_i let aim = aim_i != null ? rows[aim_i] : null let left_out = type_of(legs) == "list" ? len(legs) - count : 0 let done = at_ms != null && count > 0 && next_i == null && now_i == null let until = next_i != null ? trip_timeline_until(rows[next_i].dep, at_ms) : "" <section class={ui_cls(["ui-triptl", class])} aria-label={title} @mount={(el) => { box = el since = trip_timeline_clock() if base == null { base = since } }}> <div.ui-triptl-in> <div.ui-triptl-side> <h3.ui-triptl-title>{title}</h3> {aim != null && <p.ui-triptl-next data-state={next_i != null ? "next" : "now"}> {next_i != null ? `Next: ${aim.leg.title ?? "Your next leg"} ${until}` : `Under way: ${aim.leg.title ?? "This leg"}`} <span>{date.format(aim.leg.departs, "ddd D MMM, HH:mm")} in {aim.leg.from ?? "local time"}</span> </p>} {done && <p.ui-triptl-next data-state="done">That was the last leg.</p>} {aim != null && <button.ui-triptl-go type="button" @click={() => { let el = box.querySelector(`[data-mark="${next_i != null ? "next" : "now"}"]`) if el != null { let calm = js("matchMedia", "(prefers-reduced-motion: reduce)").matches el.focus({ preventScroll: true }) el.scrollIntoView({ block: "center", behavior: calm ? "auto" : "smooth" }) } }}>Go to next</button>} {left_out > 0 && <p.ui-triptl-where>{left_out == 1 ? "One leg has no time that can be read." : `${left_out} legs have no time that can be read.`}</p>} </div> {count == 0 ? <p.ui-triptl-empty>No legs on this trip yet.</p> : <ol.ui-triptl-list> {for pair in enumerate(rows) => { let i = pair[0] let r = pair[1] let before = i > 0 ? rows[i - 1] : null let new_day = before == null || trip_timeline_day(before.leg.departs) != trip_timeline_day(r.leg.departs) let gap = before != null ? trip_timeline_between(before, r) : null let inside = r.arrives != null ? trip_timeline_shift(r.dep_off, r.arr_off) : "" <li.ui-triptl-item key={r.id}> {new_day && <h4.ui-triptl-day>{date.format(r.leg.departs, "dddd D MMMM")}</h4>} {gap != null && (gap.wait != "" || gap.shift != "") && <div.ui-triptl-gap> {gap.wait != "" && <span.ui-triptl-wait>{gap.wait} between</span>} {gap.shift != "" && <span.ui-triptl-shift>{gap.shift}</span>} </div>} <button.ui-triptl-leg type="button" data-id={str(r.id)} data-mark={marks[i]} aria-current={marks[i] == "next" ? "step" : null} @click={() => { on_pick && on_pick(r.leg) }}> <span.ui-triptl-icon aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d={trip_timeline_icon(r.leg.kind)}/></svg></span> <span.ui-triptl-name>{r.leg.title ?? "A leg"}</span> <TripTimelineTimes label={r.leg.kind == "stay" ? "Check in," : "From"} t={r.leg.departs} place={r.leg.from}/> {r.arrives != null && <TripTimelineTimes label={r.leg.kind == "stay" ? "Check out," : "To"} t={r.arrives} place={r.leg.to} note={trip_timeline_day_change(r.leg.departs, r.arrives)}/>} <span.ui-triptl-more>{r.arr != null ? trip_timeline_span(r.arr - r.dep) : ""}{r.arr != null && inside != "" ? ". " : ""}{inside}{(r.arr != null || inside != "") && r.leg.ref != null ? ". " : ""}{r.leg.ref ?? ""}{r.leg.note != null ? ` ${r.leg.note}` : ""}</span> </button> </li> }} </ol>} </div> </section>}Related
BoardingPassCardA boarding pass with its code ready at full brightness, the gate and time large, a countdown, and offline working.3 views · 8 downloadsSeatPickerSheetPick seats in a sheet by zoom and tap, with fares, who sits beside you, seats found together and accessible seats.3 views · 5 downloadsTransitBoardDepartures from a stop as a live list from data: minutes away, delays, platforms, cancelled services, and a warning when the data has gone stale.2 views · 7 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 6 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloads
Thread
Thread 0 posts
On TripTimeline: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.