Travel

TripItinerary

A 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.

#travel#timeline#schedule#list#no-js

Demo

Preview

Portugal, November 2026

4 days · 1 flight · 1 journey by land or sea · 1 stay · 1 activity

  1. Day 1

    Lisbon
    1. to 10:20London to Lisbon TAP Air Portugal TP 1351 · Seat 14A · Terminal 2 Reference X7K2QL Confirmed
    2. Transfer to the hotel Driver waits in arrivals with a sign Confirmed
    3. Check in: Casa do Alfama Rua de São Miguel 12, Lisbon Reference 88412097 Confirmed
    4. Dinner at Taberna da Rua Table for 2 Pending
  2. Day 2

    Today Sintra
    1. to 17:30Sintra and Cascais day trip Pickup at the hotel door Reference GYG-55120 Confirmed
    2. Any timeBring a jacket: Sintra is cooler and often foggy
  3. Day 3

    Lisbon

    Nothing planned yet.

  4. Day 4

    Porto
    1. Check out: Casa do Alfama
    2. to 15:28Lisbon to Porto, Alfa Pendular 133 Coach 21, seats 45 and 46 Reference CP-7731
    3. Port wine cellar tour Cancelled

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

Example

The code of the preview above.

demos/trip_itinerary.qwe
view TripItineraryDemo() =    <TripItinerary id="trip-itinerary-demo" title="Portugal, November 2026" today="2026-11-06" days={[        { date: "2026-11-05", place: "Lisbon", items: [            { kind: "flight", time: "07:45", end: "10:20", title: "London to Lisbon", detail: "TAP Air Portugal TP 1351 · Seat 14A · Terminal 2",              ref: "X7K2QL", status: "confirmed", href: "#booking-flight" },            { kind: "car", time: "10:50", title: "Transfer to the hotel", detail: "Driver waits in arrivals with a sign", status: "confirmed" },            { kind: "hotel", time: "15:00", title: "Check in: Casa do Alfama", place: "Rua de São Miguel 12, Lisbon", ref: "88412097", status: "confirmed" },            { kind: "food", time: "20:30", title: "Dinner at Taberna da Rua", detail: "Table for 2", status: "pending" }] },        { date: "2026-11-06", place: "Sintra", items: [            { kind: "activity", time: "08:30", end: "17:30", title: "Sintra and Cascais day trip", detail: "Pickup at the hotel door", ref: "GYG-55120", status: "confirmed" },            { kind: "note", title: "Bring a jacket: Sintra is cooler and often foggy" }] },        { date: "2026-11-07", place: "Lisbon", items: [] },        { date: "2026-11-08", place: "Porto", items: [            { kind: "hotel", time: "11:00", title: "Check out: Casa do Alfama", out: true },            { kind: "train", time: "12:39", end: "15:28", title: "Lisbon to Porto, Alfa Pendular 133", detail: "Coach 21, seats 45 and 46", ref: "CP-7731" },            { kind: "activity", time: "18:00", title: "Port wine cellar tour", status: "cancelled" }] }]}/>

Source

ui/trip_itinerary.qwe
// TripItinerary: a whole trip day by day: each day headed with its number, date and// where it is spent, and in it the plans in order of time, each marked by what it is// (a flight, a train, a hotel stay, a car, an activity, a meal, a note) with its time,// where, a detail line, the booking reference and whether it is confirmed. A line at// the top counts the trip: its days, flights, stays and activities. Today's day can be// marked, and days before it are shown as done.////   <TripItinerary title="Portugal, November 2026" days={[//       { date: "2026-11-05", place: "Lisbon", items: [//           { kind: "flight", time: "07:45", end: "10:20", title: "London to Lisbon", detail: "TAP Air Portugal TP 1351 · seat 14A",//             ref: "X7K2QL", status: "confirmed" },//           { kind: "hotel", time: "15:00", title: "Check in: Casa do Alfama", place: "Rua de São Miguel 12", ref: "88412097" }] },//       { date: "2026-11-06", place: "Sintra", items: [//           { kind: "activity", time: "08:30", title: "Sintra and Cascais day trip", status: "pending" }] }]}/>//// days: a list of { date ("YYYY-MM-DD", shown "Thu 5 Nov"), place?, items }. Each// item: { kind, time?, end?, title, place?, detail?, ref?, status?, href?, out? }.// kind: "flight", "train", "bus", "ferry", "car", "hotel", "activity", "food" or// "note" (its icon and colour). time, end: as they should read ("07:45"); an item// without a time is "Any time". ref: a booking reference, shown so it can be copied.// status: "confirmed", "pending" or "cancelled" (struck through). href: makes the// title a link (the booking). out: true on a hotel's check-out, so the stay is counted// once. title: the heading. today: "YYYY-MM-DD"; its day is marked "Today" and the// days before it are shown done. summary: false drops the counts. id, class.import ui/theme.qwe use ui_clslet trip_itinerary_css = css`.ui-tripitin { display: grid; gap: 14px; min-width: 0; max-width: 680px; color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-tripitin-head { display: grid; gap: 2px; }.ui-tripitin .ui-tripitin-title { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -.01em; }.ui-tripitin-counts { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-tripitin-days { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }.ui-tripitin-day { border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); overflow: hidden; }.ui-tripitin-today { border-color: var(--ui-accent); box-shadow: 0 0 0 1px var(--ui-accent); }.ui-tripitin-past { opacity: .62; }.ui-tripitin-dayhead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 10px 16px; border-bottom: 1px solid var(--ui-border);  background: var(--ui-subtle); }.ui-tripitin .ui-tripitin-dayname { margin: 0; font-size: 15px; font-weight: 650; }.ui-tripitin-date { color: var(--ui-muted); font-size: 13px; }.ui-tripitin-place { margin-left: auto; color: var(--ui-muted); font-size: 13px; font-weight: 500; }.ui-tripitin-now { padding: 1px 8px; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 11px; font-weight: 700;  letter-spacing: .04em; text-transform: uppercase; }.ui-tripitin-items { margin: 0; padding: 4px 0; list-style: none; }.ui-tripitin-item { --ui-tripitin-k: var(--ui-accent); display: grid; grid-template-columns: 74px 34px minmax(0, 1fr) auto; gap: 2px 12px; align-items: start;  padding: 10px 16px; }.ui-tripitin-item + .ui-tripitin-item { border-top: 1px dashed var(--ui-border); }.ui-tripitin-k-hotel { --ui-tripitin-k: var(--ui-success); }.ui-tripitin-k-activity { --ui-tripitin-k: var(--ui-warning); }.ui-tripitin-k-food { --ui-tripitin-k: var(--ui-danger); }.ui-tripitin-k-train, .ui-tripitin-k-bus, .ui-tripitin-k-ferry, .ui-tripitin-k-car { --ui-tripitin-k: var(--ui-info); }.ui-tripitin-k-note { --ui-tripitin-k: var(--ui-muted); }.ui-tripitin-time { padding-top: 6px; font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-tripitin-time small { display: block; color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-tripitin-any { color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-tripitin-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: var(--ui-tripitin-k);  background: color-mix(in srgb, var(--ui-tripitin-k) 14%, transparent); }.ui-tripitin-icon svg { width: 18px; height: 18px; }.ui-tripitin-what { display: grid; gap: 1px; min-width: 0; padding-top: 2px; }.ui-tripitin-what b { font-weight: 600; }.ui-tripitin-what a { color: inherit; text-decoration: none; }.ui-tripitin-what a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-tripitin-what a:focus-visible { outline: none; border-radius: 2px; box-shadow: var(--ui-focus); }.ui-tripitin-what span { color: var(--ui-muted); font-size: 13px; }.ui-tripitin-ref { justify-self: start; margin-top: 3px; padding: 1px 6px; border: 1px dashed var(--ui-strong); border-radius: 4px; color: var(--ui-fg);  font: 600 12px var(--ui-mono); letter-spacing: .04em; user-select: all; }.ui-tripitin-status { align-self: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; }.ui-tripitin-confirmed { background: var(--ui-success-soft); color: var(--ui-success); }.ui-tripitin-pending { background: var(--ui-warning-soft); color: var(--ui-warning); }.ui-tripitin-cancelled { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-tripitin-off .ui-tripitin-what b { text-decoration: line-through; color: var(--ui-muted); }.ui-tripitin-empty { margin: 0; padding: 12px 16px; color: var(--ui-muted); font-size: 13px; }@media (max-width: 520px) {  .ui-tripitin-item { grid-template-columns: 34px minmax(0, 1fr); }  .ui-tripitin-time { grid-column: 2; padding-top: 0; }  .ui-tripitin-icon { grid-row: 1 / span 3; grid-column: 1; }  .ui-tripitin-status { grid-column: 2; justify-self: start; }}`let trip_itinerary_icons = {    flight: <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="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"/></svg>,    train: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="3" width="14" height="14" rx="3"/><path d="M5 11h14M9 21l1.5-4M15 21l-1.5-4"/><circle cx="9" cy="14" r=".5"/><circle cx="15" cy="14" r=".5"/></svg>,    bus: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="3" width="16" height="15" rx="2"/><path d="M4 11h16M7 18v3M17 18v3"/></svg>,    ferry: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 18c2 1.5 4 1.5 6 0s4-1.5 6 0 4 1.5 6 0M5 15l-1-5h16l-1 5M8 10V6h8v4M12 3v3"/></svg>,    car: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 17H3v-5l2-5h14l2 5v5h-2M7 17h10"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>,    hotel: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 19V6M3 15h18v4M21 15v-3a3 3 0 0 0-3-3h-7v6"/><circle cx="7" cy="11" r="2"/></svg>,    activity: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 20l6-11 4 6 3-4 5 9z"/><circle cx="17" cy="5" r="2"/></svg>,    food: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3v8M5 3v5a2 2 0 0 0 4 0V3M7 11v10M17 21V3c-2 1-3 4-3 8h3"/></svg>,    note: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4h14v12l-4 4H5z"/><path d="M15 20v-4h4M9 9h6M9 13h4"/></svg>,}let trip_itinerary_names = { flight: "Flight", train: "Train", bus: "Bus", ferry: "Ferry", car: "Car", hotel: "Stay", activity: "Activity", food: "Meal", note: "Note" }let trip_itinerary_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]let trip_itinerary_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// "YYYY-MM-DD" as "Thu 5 Nov"; other text as it is.fn trip_itinerary_date(d: str) -> str {    if !date.valid(d, "date") { return d }    let p = date.parts(d)    `${trip_itinerary_days[p.weekday - 1]} ${p.day} ${trip_itinerary_months[p.month - 1]}`}// "3 flights", "1 stay": a count and its word, or null for none.fn trip_itinerary_count(n: int, one: str, many: str) -> str? = n == 0 ? null : `${n} ${n == 1 ? one : many}`view TripItinerary(days: list, title: str? = null, today: str? = null, summary: bool = true, id: str? = null, class: str? = null) {    style global trip_itinerary_css    let all = flatten(days | map((d) => d.items ?? []))    let n = (k: str) => len(all | filter((i) => i.kind == k && i.status != "cancelled"))    let counts = [        trip_itinerary_count(len(days), "day", "days"),        trip_itinerary_count(n("flight"), "flight", "flights"),        trip_itinerary_count(n("train") + n("bus") + n("ferry"), "journey by land or sea", "journeys by land or sea"),        trip_itinerary_count(len(all | filter((i) => i.kind == "hotel" && i.status != "cancelled" && i.out != true)), "stay", "stays"),        trip_itinerary_count(n("activity"), "activity", "activities"),    ] | filter((c) => c != null)    let base = id ?? "trip-itinerary"    <section class={ui_cls(["ui-tripitin", class])} id={id} aria-labelledby={title != null ? `${base}-title` : null} aria-label={title == null ? "Itinerary" : null}>        {(title != null || summary) && <div.ui-tripitin-head>            {title != null && <h3.ui-tripitin-title id={`${base}-title`}>{title}</h3>}            {summary && <p.ui-tripitin-counts>{join(counts, " · ")}</p>}        </div>}        <ol.ui-tripitin-days>            {for p in enumerate(days) => {                let d = p[1]                let is_today = today != null && d.date == today                let past = today != null && d.date < today                let items = d.items ?? []                <li class={ui_cls(["ui-tripitin-day", is_today && "ui-tripitin-today", past && "ui-tripitin-past"])} key={d.date} data-date={d.date}                    aria-current={is_today ? "date" : null}>                    <div.ui-tripitin-dayhead>                        <h4.ui-tripitin-dayname>Day {p[0] + 1}</h4>                        {" "}<time.ui-tripitin-date datetime={d.date}>{trip_itinerary_date(d.date)}</time>                        {is_today && [" ", <span.ui-tripitin-now>Today</span>]}                        {d.place != null && [" ", <span.ui-tripitin-place>{d.place}</span>]}                    </div>                    {len(items) == 0                        ? <p.ui-tripitin-empty>Nothing planned yet.</p>                        : <ol.ui-tripitin-items>                            {for it in items => {                                let kind = trip_itinerary_icons[it.kind] != null ? it.kind : "note"                                let off = it.status == "cancelled"                                <li class={ui_cls(["ui-tripitin-item", `ui-tripitin-k-${kind}`, off && "ui-tripitin-off"])} data-kind={kind}>                                    <span.ui-tripitin-time>                                        {it.time != null ? <time>{it.time}</time> : <span.ui-tripitin-any>Any time</span>}                                        {it.end != null && [" ", <small>to {it.end}</small>]}                                    </span>                                    <span.ui-tripitin-icon role="img" aria-label={trip_itinerary_names[kind]}>{trip_itinerary_icons[kind]}</span>                                    <span.ui-tripitin-what>                                        <b>{it.href != null ? <a href={it.href}>{it.title}</a> : it.title}</b>                                        {it.place != null && [" ", <span>{it.place}</span>]}                                        {it.detail != null && [" ", <span>{it.detail}</span>]}                                        {it.ref != null && [" ", <code.ui-tripitin-ref><span.ui-sr>Reference </span>{it.ref}</code>]}                                    </span>                                    {it.status != null && [" ", <span class={ui_cls(["ui-tripitin-status", `ui-tripitin-${it.status}`])}>                                        {it.status == "confirmed" ? "Confirmed" : (it.status == "pending" ? "Pending" : (it.status == "cancelled" ? "Cancelled" : it.status))}                                    </span>]}                                </li>                            }}                          </ol>}                </li>            }}        </ol>    </section>}
Thread
910Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.