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.
Demo
Cais do Sodre
14:05Updated just now
Next: 15E to Praca da Figueira, Due- 15EPraca da Figueira14:05On timePlatform 1, step freeDue
- 714Outurela14:11On timePlatform 4 (was 3)6 min
- 15EAlges
14:09+3 minPlatform 27 min - CascaisCascais14:20Platform 6Cancelled
- CascaisOeiras15:12On timePlatform 515:12
One departure has no time that can be read.
By the phone's own clock
- 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.qwemain.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>}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 downloadsTripTimelineThe 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.2 views · 6 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 TransitBoard: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.