Mobile

TransitBoard

Departures from a stop as a live list from data: minutes away, delays, platforms, cancelled services, and a warning when the data has gone stale.

#mobile#interactive#tablet#transit#travel#live

Demo

Preview

Cais do Sodre

14:05

Updated just now

Next: 15E to Praca da Figueira, Due
  1. 15EPraca da Figueira14:05On timePlatform 1, step freeDue
  2. 714Outurela14:11On timePlatform 4 (was 3)6 min
  3. 15EAlges14:09+3 minPlatform 27 min
  4. CascaisCascais14:20Platform 6Cancelled
  5. CascaisOeiras15:12On timePlatform 515:12

One departure has no time that can be read.

By the phone's own clock

Next: 28E to Martim Moniz, 09:30
  1. 28EMartim Moniz09:30On timePlatform 109:30

Refreshed 0 times.

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/transit_board.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/transit_board.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/transit_board.qwe
let transit_board_demo_first = [    { id: "a", line: "15E", to: "Alges", at: "2026-11-03T14:09:00+00:00", delay: 3, platform: "2", colour: "#c0392b" },    { id: "b", line: "15E", to: "Praca da Figueira", at: "2026-11-03T14:05:30+00:00", platform: "1", colour: "#c0392b", step_free: true },    { id: "c", line: "714", to: "Outurela", at: "2026-11-03T14:11:00+00:00", platform: "4", was_platform: "3", colour: "#2980b9" },    { id: "d", line: "Cascais", to: "Cascais", at: "2026-11-03T14:20:00+00:00", cancelled: true, platform: "6", colour: "#16a085" },    { id: "e", line: "Cascais", to: "Oeiras", at: "2026-11-03T15:12:00+00:00", platform: "5", colour: "#fc0" },    { id: "f", line: "714", to: "Lost its time", at: "soon", platform: "4" },    { id: "g", line: "15E", to: "Long gone", at: "2026-11-03T14:01:00+00:00", platform: "1", colour: "#c0392b" },]component TransitBoardDemoLive() {    state time = "2026-11-03T14:05:00+00:00"    state data = transit_board_demo_first    state updated = "2026-11-03T14:04:30+00:00"    state refreshed = 0    <div.demo-stack>        <TransitBoard stop="Cais do Sodre" departures={data} now={time} updated={updated} tick={1} on_refresh={() => {            refreshed += 1            updated = time        }}/>        <div.demo-row>            <button.transit-board-demo-later type="button" style="min-height: 44px" @click={() => { time = "2026-11-03T14:10:40+00:00" }}>Jump to 14:10:40</button>            <button.transit-board-demo-delay type="button" style="min-height: 44px" @click={() => {                data = data | map((d) => d.id == "c" ? { ...d, expected: "2026-11-03T14:31:00+00:00" } : d)            }}>Delay the 714</button>        </div>        <TransitBoard class="transit-board-demo-clockless" stop="By the phone's own clock" departures={[{ id: "z", line: "28E", to: "Martim Moniz", at: "2030-06-01T09:30:00+01:00", platform: "1" }]}/>        <p.demo-note>Refreshed <span class="transit-board-demo-refreshed">{refreshed}</span> times.</p>    </div>}view TransitBoardDemo() = <TransitBoardDemoLive/>

Source

ui/transit_board.qwe
// TransitBoard: the departures from a stop, as a platform's board has them: the line, where it// goes, the platform, and the minutes until it leaves, counted down by itself. A delay moves a// departure to where it belongs and shows both times; a cancelled one stays, struck out,// until its time has gone. It says when its data has gone stale, or the phone is offline, as a// board that looks live but is not is worse than no board. Data comes from the page: give it// new `departures` and the rows update in place, with no server call of its own.////   import ui/transit_board.qwe use TransitBoard////   <TransitBoard stop="Cais do Sodre" now="2026-11-03T14:05:00+00:00" updated="2026-11-03T14:04:30+00:00" departures={[//       { id: "t1", line: "15E", to: "Alges", at: "2026-11-03T14:09:00+00:00", delay: 3, platform: "2" },//   ]} on_refresh={() => { departures = fetch_them() }}/>//// stop: the stop's name. departures: a list of { id, line, to, at, delay?, expected?, platform?,// was_platform?, cancelled?, colour?, step_free? }. at: the scheduled time, an ISO date-time// with its offset, shown as written, so in the stop's own time. delay: minutes late (0), or// expected: the date-time it now leaves, which wins. colour: the line's, as #rgb or #rrggbb. A row// whose time cannot be read is left out, and counted. now: the time at the stop, the same// form, so the server's page has the same words as the browser's; left out, the page first// shows clock times, then minutes from the phone's own clock. tick: seconds between looks at the// clock (15). updated: the date-time the data was fetched. stale: minutes after which that// is said (3). grace: seconds a departure stays after its time, as a tram at the stop is// still there to catch (30). limit: rows at most (0, all). on_refresh(): shows a Refresh// button, for the page to fetch again, inside one of your components.//// transit_board_rows and transit_board_wait are the working.import ui/theme.qwe use ui_clslet transit_board_css = css`.ui-tboard { position: relative; container-type: inline-size; font: 15px/1.35 var(--ui-font); color: var(--ui-fg); }.ui-tboard-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 12px; margin-bottom: 8px; }.ui-tboard-stop { margin: 0; font-size: 20px; font-weight: 700; overflow-wrap: anywhere; }.ui-tboard-clock { font: 600 18px/1 var(--ui-mono); font-variant-numeric: tabular-nums; }.ui-tboard-note { margin: 0 0 8px; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-fg); font-size: 14px; }.ui-tboard-note:empty { display: none; }.ui-tboard-fresh { margin: 0 0 8px; color: var(--ui-muted); font-size: 13px; }.ui-tboard-fresh:empty { display: none; }.ui-tboard-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 10px; }.ui-tboard-chip {  box-sizing: border-box; min-width: 44px; min-height: 44px; padding: 0 14px; 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-tboard-chip[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-tboard-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-tboard-refresh { margin-left: auto; }.ui-tboard-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ui-border); }.ui-tboard-row {  display: grid; grid-template-columns: 66px minmax(0, 1fr) auto; align-items: center; gap: 2px 10px; min-height: 60px; padding: 8px 2px; border-bottom: 1px solid var(--ui-border);}.ui-tboard-line {  grid-row: 1 / span 2; display: grid; place-items: center; min-height: 34px; padding: 2px 4px; border-radius: 6px; background: var(--c, var(--ui-fg)); color: var(--i, var(--ui-bg));  font-weight: 700; font-size: 13px; overflow-wrap: anywhere; text-align: center;}.ui-tboard-to { font-weight: 600; overflow-wrap: anywhere; }.ui-tboard-wait { grid-column: 3; grid-row: 1 / span 2; font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }.ui-tboard-sub { display: flex; flex-wrap: wrap; gap: 0 8px; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-tboard-sub-times { display: inline-flex; gap: 0 6px; }.ui-tboard-sub s { opacity: .8; }.ui-tboard-late { color: var(--ui-danger); font-weight: 600; }.ui-tboard-row[data-state="due"] .ui-tboard-wait { color: var(--ui-success); }.ui-tboard-row[data-state="cancelled"] .ui-tboard-to { text-decoration: line-through; color: var(--ui-muted); }.ui-tboard-row[data-state="cancelled"] .ui-tboard-wait { color: var(--ui-danger); font-size: 15px; }.ui-tboard-empty { padding: 20px 8px; color: var(--ui-muted); text-align: center; }.ui-tboard-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }/* With the room it is a board: columns that line up, the platform on its own. */@container (min-width: 620px) {  .ui-tboard-row { grid-template-columns: 72px minmax(0, 1fr) 150px 110px; }  .ui-tboard-line { grid-row: auto; }  .ui-tboard-sub { display: contents; }  .ui-tboard-sub-times { grid-column: 3; grid-row: 1; display: flex; flex-wrap: wrap; gap: 0 8px; color: var(--ui-muted); font-size: 14px; }  .ui-tboard-sub-where { grid-column: 2; grid-row: 2; color: var(--ui-muted); font-size: 13px; }  .ui-tboard-wait { grid-column: 4; grid-row: 1 / span 2; }  .ui-tboard-to { grid-column: 2; grid-row: 1; font-size: 17px; }  .ui-tboard-line { grid-column: 1; grid-row: 1 / span 2; }}`fn transit_board_clock() = now()fn transit_board_num(x) = (type_of(x) == "int" || type_of(x) == "float" || type_of(x) == "dec") && math.isfinite(float(x))fn transit_board_time(s) = type_of(s) == "str" && date.valid(s, "date-time")// A colour fit for a style attribute: data may not write CSS.fn transit_board_colour(c) = type_of(c) == "str" && re.test(c, "^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$") ? c : null// Black or white, whichever reads on a line's colour: a colour comes from data, and a pale one// with white text cannot be read. By the usual weights for red, green and blue.fn transit_board_ink(c) {    if c == null { return null }    let digits = split(lower(slice(c, 1, len(c))), "")    let hex = (d) => index_of(split("0123456789abcdef", ""), d) ?? 0    let wide = len(digits) == 3 ? [hex(digits[0]) * 17, hex(digits[1]) * 17, hex(digits[2]) * 17]        : [hex(digits[0]) * 16 + hex(digits[1]), hex(digits[2]) * 16 + hex(digits[3]), hex(digits[4]) * 16 + hex(digits[5])]    wide[0] * 0.299 + wide[1] * 0.587 + wide[2] * 0.114 > 160 ? "#111" : "#fff"}// HH:mm of a moment, at the offset the stop's own time was written in.fn transit_board_clock_text(ms, off) = date.format(date.from_ms(ms, off), "HH:mm")// "Due", "4 min", or past the hour the clock's time, which is how a board says it.fn transit_board_wait(ms, now_ms, off) {    let mins = floor((ms - now_ms) / 60000)    if mins < 1 { return "Due" }    mins < 60 ? `${mins} min` : transit_board_clock_text(ms, off)}// Rows in the order they leave: { id, line, to, ms, late (whole minutes, not below 0),// wait, state ("due", "soon", "later" or "cancelled"), ... }. Gone departures are left out, and// `now_ms` null keeps everything, for a page with no clock yet.fn transit_board_rows(deps, now_ms, grace: num = 30) {    let good = (type_of(deps) == "list" ? deps : []) | filter((d) => type_of(d) == "map" && transit_board_time(d.at))    let made = good | map((d) => {        let sched = date.ms(d.at)        let off = date.parts(d.at).offset        let exp = transit_board_time(d.expected) ? date.ms(d.expected) : sched + (transit_board_num(d.delay) ? round(float(d.delay) * 60000) : 0)        let out = d.cancelled == true        let when = out ? sched : exp        let late = out ? 0 : round((exp - sched) / 60000)        { id: d.id ?? `${d.line}-${d.at}`, line: str(d.line ?? ""), to: str(d.to ?? ""), platform: d.platform, was: d.was_platform, colour: transit_board_colour(d.colour),          step_free: d.step_free == true, ms: when, sched: transit_board_clock_text(sched, off), exp: transit_board_clock_text(exp, off), late: late, out: out, off: off,          wait: now_ms == null ? transit_board_clock_text(when, off) : (out ? "Cancelled" : transit_board_wait(when, now_ms, off)),          state: out ? "cancelled" : (now_ms == null ? "later" : (when - now_ms < 60000 ? "due" : (when - now_ms < 600000 ? "soon" : "later"))) }    })    let kept = now_ms == null ? made : made | filter((r) => r.ms + grace * 1000 >= now_ms)    kept | sort_by((r) => r.ms)}// The lines on the board, in the order they first come.fn transit_board_lines(rows) = unique(rows | map((r) => r.line))// "12 s ago" is no use: a board's data is minutes old or it is not.fn transit_board_age(updated_ms, now_ms) {    let m = floor((now_ms - updated_ms) / 60000)    m < 1 ? "just now" : (m == 1 ? "1 minute ago" : `${m} minutes ago`)}fn transit_board_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}`}component TransitBoard(stop: str = "Departures", departures = [], now: str? = null, tick: num = 15, updated: str? = null, stale: num = 3,                       grace: num = 30, limit: int = 0, on_refresh = null, class: str? = null) {    style global transit_board_css    state base = transit_board_time(now) ? date.ms(now) : null    state offset = transit_board_time(now) ? date.parts(now).offset : null    state seen_now = now    state since = 0    state moved_on = 0    state online = true    state only = null    if now != seen_now {        seen_now = now        if transit_board_time(now) {            base = date.ms(now)            offset = date.parts(now).offset            since = transit_board_clock()            moved_on = 0        }    }    if tick > 0 && base != null { every(round(tick * 1000), () => { moved_on = transit_board_clock() - since }) }    let at_ms = base == null ? null : base + moved_on    let all = transit_board_rows(departures, at_ms, grace)    let lines = transit_board_lines(all)    let chosen = only != null && contains(lines, only) ? only : null    let some = chosen == null ? all : all | filter((r) => r.line == chosen)    let rows = limit > 0 ? take(some, limit) : some    let left_out = type_of(departures) == "list" ? len(departures | filter((d) => type_of(d) != "map" || !transit_board_time(d.at))) : 0    let age_ms = transit_board_time(updated) && at_ms != null ? at_ms - date.ms(updated) : null    let old = age_ms != null && age_ms > stale * 60000    let warn = !online ? `You are offline. Times are from ${transit_board_time(updated) ? date.format(updated, "HH:mm") : "the last update"} and may have changed.`        : (old ? `These times may be out of date. Last update ${date.format(updated, "HH:mm")}.` : "")    let fresh = !old && online && age_ms != null ? `Updated ${transit_board_age(date.ms(updated), at_ms)}` : ""    let next_say = len(rows) > 0 ? `Next: ${rows[0].line} to ${rows[0].to}, ${rows[0].wait}` : ""    <section class={ui_cls(["ui-tboard", class])} aria-label={`Departures from ${stop}`} @mount={(_el) => {        let win = js("window")        since = transit_board_clock()        if base == null {            offset = transit_board_offset()            base = since        }        online = win.navigator.onLine != false        let up = (_e) => { online = true }        let down = (_e) => { online = false }        win.addEventListener("online", up)        win.addEventListener("offline", down)        () => {            win.removeEventListener("online", up)            win.removeEventListener("offline", down)        }    }}>        <header.ui-tboard-head>            <h3.ui-tboard-stop>{stop}</h3>            <span.ui-tboard-clock>{at_ms != null ? transit_board_clock_text(at_ms, offset) : ""}</span>        </header>        <p.ui-tboard-note role="status">{warn}</p>        <p.ui-tboard-fresh>{fresh}</p>        <div.ui-tboard-bar role="group" aria-label="Lines">            {len(lines) > 1 && <button.ui-tboard-chip type="button" data-line="" aria-pressed={chosen == null ? "true" : "false"} @click={() => { only = null }}>All</button>}            {len(lines) > 1 && for l in lines => <button.ui-tboard-chip key={l} type="button" data-line={l} aria-pressed={chosen == l ? "true" : "false"}                @click={() => { only = l }}>{l}</button>}            {on_refresh != null && <button.ui-tboard-chip.ui-tboard-refresh type="button" @click={() => { on_refresh() }}>Refresh</button>}        </div>        <span.ui-tboard-sr aria-live="polite">{next_say}</span>        {len(rows) == 0            ? <p.ui-tboard-empty>{chosen != null ? `No more ${chosen} departures on the list.` : "No departures on the list."}</p>            : <ol.ui-tboard-list>                  {for r in rows => <li.ui-tboard-row key={r.id} data-id={str(r.id)} data-state={r.state}>                      <span.ui-tboard-line style={r.colour != null ? `--c: ${r.colour}; --i: ${transit_board_ink(r.colour)}` : null}>{r.line}</span>                      <span.ui-tboard-to>{r.to}</span>                      <span.ui-tboard-sub>                          <span.ui-tboard-sub-times>                              {r.late != 0 ? <s>{r.sched}</s> : <span>{r.sched}</span>}                              {r.late != 0 && <span.ui-tboard-late>{r.late > 0 ? `+${r.late} min` : `${r.late} min`}</span>}                              {r.late == 0 && !r.out && <span>On time</span>}                          </span>                          <span.ui-tboard-sub-where>                              {r.platform != null ? `Platform ${r.platform}` : ""}{r.was != null && r.was != r.platform ? ` (was ${r.was})` : ""}{r.step_free ? ", step free" : ""}                          </span>                      </span>                      <span.ui-tboard-wait>{r.wait}</span>                  </li>}              </ol>}        {left_out > 0 && <p.ui-tboard-fresh>{left_out == 1 ? "One departure has no time that can be read." : `${left_out} departures have no time that can be read.`}</p>}    </section>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.