FareCalendar
A 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.
Demo
Cheapest fares, November 2026
From $79Pick a day
Pick a day: on_pick gets it and its fare.
Cheapest fares, December 2026
From 59,90 €Thursday 10 December · 71,00 €
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/fare_calendar.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/fare_calendar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/fare_calendar.qwe
// A month and a half of fares, cheaper midweek and dearer at weekends, with a few days without flights.let fare_calendar_demo_prices = { "2026-11-02": 129.00m, "2026-11-03": 89.00m, "2026-11-04": 94.50m, "2026-11-05": 112.00m, "2026-11-06": 158.00m, "2026-11-07": 171.00m, "2026-11-08": 149.00m, "2026-11-09": 119.00m, "2026-11-10": 79.00m, "2026-11-11": 84.00m, "2026-11-12": 99.00m, "2026-11-13": 164.00m, "2026-11-14": 182.00m, "2026-11-15": 140.00m, "2026-11-16": 109.00m, "2026-11-17": 92.00m, "2026-11-18": 92.00m, "2026-11-19": 118.00m, "2026-11-20": 199.00m, "2026-11-21": 214.00m, "2026-11-22": 176.00m, "2026-11-23": 121.00m, "2026-11-24": 101.00m, "2026-11-26": 133.00m, "2026-11-27": 245.00m, "2026-11-28": 239.00m, "2026-11-29": 198.00m, "2026-11-30": 115.00m, "2026-10-30": 99.00m, "2026-10-31": 139.00m, "2026-12-01": 104.00m, "2026-12-02": 88.00m, "2026-12-03": 97.00m, "2026-12-04": 151.00m, "2026-12-05": 166.00m,}component FareCalendarDemoLive() { state note = "Pick a day: on_pick gets it and its fare." <div.demo-stack> <FareCalendar id="fare-calendar-demo" name="depart" prices={fare_calendar_demo_prices} month="2026-11" today="2026-10-31" on_pick={(day, price) => { note = `on_pick: ${day} at ${price}` }}/> <p.demo-note id="fare-calendar-demo-note">{note}</p> </div>}view FareCalendarDemo() = <div class="demo-row" style="align-items: start"> <FareCalendarDemoLive/> <FareCalendar id="fare-calendar-demo-eur" name="back" prices={{ "2026-12-07": 64.90m, "2026-12-08": 59.90m, "2026-12-10": 71.00m }} value="2026-12-10" week_start={0} currency="EUR" locale="de-DE" cents={true}/> </div>Source
ui/fare_calendar.qwe
// FareCalendar: the cheapest fare for each day of a month, as a calendar to pick the// day to fly: each day shows its price, coloured as cheap, middling or dear for the// month, the cheapest day marked, days with no flights or already past greyed out, and// the month's lowest fare beside its name. The arrows beside the name go to the months// before and after that have fares. The arrow keys move by day and by week within the// month, and Enter or Space picks. A plain <form> gets the day picked ("YYYY-MM-DD")// as `name`.//// <FareCalendar month="2026-11" prices={{ "2026-11-02": 129.00m, "2026-11-03": 89.00m, "2026-11-04": 94.50m }}// today="2026-10-30" name="depart" on_pick={(day, price) => { depart = day }}/>//// prices: a map of "YYYY-MM-DD" to that day's lowest fare (decimals best; numbers are// read as decimals); a day not in it has no flights. month: "YYYY-MM" shown first (the// month of value, else of the first day priced). value: the day picked to start with.// today: the days before it cannot be picked (none by default). week_start: 1 for// Monday (default), 0 for Sunday. currency: ISO code ("USD"); locale: how amounts are// written ("en-US"); cents: false (default) writes whole fares without them ("$89").// name: what a form gets the day as ("date"). label: what screen readers hear before// the month ("Cheapest fares"). on_pick(day, price): inside one of your components,// given the day and its fare (a decimal) when one is picked. id, class.import ui/theme.qwe use ui_clslet fare_calendar_css = css`.ui-farecal { display: grid; gap: 10px; min-width: 0; max-width: 520px; padding: 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.3 var(--ui-font); font-variant-numeric: tabular-nums; }.ui-farecal-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; }.ui-farecal-title { display: grid; justify-items: center; text-align: center; }.ui-farecal .ui-farecal-month { margin: 0; font-size: 16px; font-weight: 650; }.ui-farecal-from { color: var(--ui-muted); font-size: 12px; }.ui-farecal-from b { color: var(--ui-success); font-weight: 650; }.ui-farecal-nav { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%; background: var(--ui-surface); color: var(--ui-fg); cursor: pointer; }.ui-farecal-nav svg { width: 16px; height: 16px; }.ui-farecal-nav:hover:not(:disabled) { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-farecal-nav:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-farecal-nav:disabled { opacity: .35; cursor: not-allowed; }.ui-farecal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }.ui-farecal-wd { padding: 2px 0 4px; color: var(--ui-muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; text-align: center; text-transform: uppercase; }.ui-farecal-day { --ui-farecal-k: var(--ui-fg); position: relative; display: grid; align-content: center; justify-items: center; gap: 2px; min-width: 0; height: 54px; padding: 2px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px/1.1 var(--ui-font); cursor: pointer; transition: border-color .12s, background .12s; }.ui-farecal-day b { font-weight: 600; }.ui-farecal-day span { overflow: hidden; max-width: 100%; color: var(--ui-farecal-k); font-size: 11px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-farecal-low { --ui-farecal-k: var(--ui-success); }.ui-farecal-high { --ui-farecal-k: var(--ui-danger); }.ui-farecal-mid { --ui-farecal-k: var(--ui-muted); }.ui-farecal-best { border-color: var(--ui-success); background: var(--ui-success-soft); }.ui-farecal-best::after { content: ""; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--ui-success); }.ui-farecal-day:hover:not([aria-disabled="true"]) { border-color: var(--ui-accent); }.ui-farecal-day:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-farecal-day[aria-disabled="true"] { border-color: transparent; background: var(--ui-subtle); color: var(--ui-muted); cursor: not-allowed; }.ui-farecal-day[aria-disabled="true"] span { color: var(--ui-muted); font-weight: 400; }.ui-farecal-day[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-farecal-day[aria-pressed="true"] span { color: var(--ui-accent-fg); }.ui-farecal-day[aria-pressed="true"]::after { background: var(--ui-accent-fg); }.ui-farecal-today b { text-decoration: underline; text-underline-offset: 2px; }.ui-farecal-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 14px; }.ui-farecal-key { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 12px; }.ui-farecal-key li { display: inline-flex; align-items: center; gap: 5px; }.ui-farecal-key li::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ui-farecal-k); }.ui-farecal-picked { font-size: 13px; font-weight: 600; }@media (max-width: 420px) { .ui-farecal { padding: 12px 8px; } .ui-farecal-day { height: 48px; } .ui-farecal-day span { font-size: 10px; } }@media (prefers-reduced-motion: reduce) { .ui-farecal-day { transition: none; } }`let fare_calendar_months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]let fare_calendar_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]// An amount in its currency; cents: false drops the cents of a whole amount ("$89").fn fare_calendar_money(x: dec, currency: str, locale: str, cents: bool) -> str { let m = money.of(x, currency, "half_up") if type_of(m) == "error" { return `${format_number(round(x, 2, "half_up"), locale, 2)} ${currency}` } let text = money.format(m, locale) let units = money.minor_units(currency) cents || units == 0 || x != floor(x) ? text : re.replace(text, `[.,]0{${units}}(\\D*)$`, "$1")}// "2026-11" as "November 2026".fn fare_calendar_title(month: str) -> str { let p = date.parts(`${month}-01`) `${fare_calendar_months[p.month - 1]} ${p.year}`}// "2026-11-05" as "Thursday 5 November".fn fare_calendar_said(d: str) -> str { let p = date.parts(d) `${fare_calendar_days[p.weekday - 1]} ${p.day} ${fare_calendar_months[p.month - 1]}`}component FareCalendar(prices: map, month: str? = null, value: str? = null, today: str? = null, week_start: int = 1, currency: str = "USD", locale: str = "en-US", cents: bool = false, name: str = "date", label: str = "Cheapest fares", on_pick = null, id: str? = null, class: str? = null) { style global fare_calendar_css let days_priced = sort(keys(prices)) let months = days_priced | map((d) => slice(d, 0, 7)) | unique let first_month = month ?? (value != null ? slice(value, 0, 7) : (months[0] ?? slice(today ?? "2026-01-01", 0, 7))) state shown = first_month state picked = value state focus = value let base = `fare-calendar-${name}` let open = (d: str) => prices[d] != null && (today == null || d >= today) let start = `${shown}-01` let count = date.days_in_month(start) let lead_days = week_start == 0 ? date.weekday(start) % 7 : date.weekday(start) - 1 let days = range(1, count + 1) | map((n) => `${shown}-${n < 10 ? `0${n}` : str(n)}`) let fares = days | filter(open) | map((d) => dec(prices[d])) let low = min(fares) let high = max(fares) let tier = (p: dec) => { if high == null || high == low { return "low" } let third = (high - low) / 3 p <= low + third ? "low" : (p >= high - third ? "high" : "mid") } let prev = date.add_months(start, -1) let next = date.add_months(start, 1) let has_before = any(months, (m) => m < shown) let has_after = any(months, (m) => m > shown) let stop = focus != null && slice(focus, 0, 7) == shown ? focus : (days | find(open) ?? days[0]) let names = week_start == 0 ? ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] : ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] let go = (to: str) => { shown = slice(to, 0, 7) focus = null } let move = (e, by: int) => { let d = e.target.dataset.day let to = date.add_days(d, by) if slice(to, 0, 7) == shown { e.preventDefault() let el = e.target.closest(".ui-farecal-grid").querySelector(`[data-day="${to}"]`) if el != null { focus = to el.focus() } } } let pick = (d: str) => { if open(d) { picked = d focus = d on_pick && on_pick(d, dec(prices[d])) } } <section class={ui_cls(["ui-farecal", class])} id={id} aria-labelledby={`${base}-month`}> <div.ui-farecal-head> <button.ui-farecal-nav type="button" aria-label="Month before" disabled={!has_before} @click={() => go(prev)}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg> </button> <div.ui-farecal-title> <h3.ui-farecal-month id={`${base}-month`} aria-live="polite"><span.ui-sr>{label}, </span>{fare_calendar_title(shown)}</h3> <span.ui-farecal-from>{low != null ? ["From ", <b>{fare_calendar_money(low, currency, locale, cents)}</b>] : "No fares this month"}</span> </div> <button.ui-farecal-nav type="button" aria-label="Month after" disabled={!has_after} @click={() => go(next)}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg> </button> </div> <div.ui-farecal-grid role="group" aria-labelledby={`${base}-month`}> {for w in names => <span.ui-farecal-wd aria-hidden="true">{w}</span>} {for i in range(lead_days) => <span aria-hidden="true"></span>} {for d in days => { let ok = open(d) let p = prices[d] != null ? dec(prices[d]) : null let t = ok ? tier(p) : null let best = ok && p == low let price_text = p != null ? fare_calendar_money(p, currency, locale, cents) : null let said = join([fare_calendar_said(d), ok ? price_text : (p != null ? "past" : "no flights"), best ? "lowest fare" : null] | filter((x) => x != null), ", ") <button class={ui_cls(["ui-farecal-day", t != null && `ui-farecal-${t}`, best && "ui-farecal-best", d == today && "ui-farecal-today"])} key={d} type="button" data-day={d} aria-label={said} aria-pressed={d == picked ? "true" : "false"} aria-disabled={ok ? null : "true"} tabindex={d == stop ? "0" : "-1"} @click={() => pick(d)} @keydown={(e) => { match e.key { "ArrowRight" => move(e, 1) "ArrowLeft" => move(e, -1) "ArrowDown" => move(e, 7) "ArrowUp" => move(e, -7) _ => null } }}> <b>{int(slice(d, 8, 10))}</b> {" "}<span aria-hidden="true">{ok ? price_text : "–"}</span> </button> }} </div> <div.ui-farecal-foot> <ul.ui-farecal-key aria-hidden="true"> <li.ui-farecal-low>Cheap</li><li.ui-farecal-mid>Usual</li><li.ui-farecal-high>Dear</li> </ul> <span.ui-farecal-picked aria-live="polite"> {picked != null ? `${fare_calendar_said(picked)} · ${fare_calendar_money(dec(prices[picked] ?? 0), currency, locale, cents)}` : "Pick a day"} </span> </div> <input type="hidden" name={name} value={picked ?? ""}/> </section>}Related
SeatMapA plane's cabin to pick a seat for each passenger: aisles, window and aisle seats, taken seats, exit and extra legroom rows, a price per seat and the total.8 views · 7 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 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 · 11 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 downloadsFlightStatusA flight's status today: on time, delayed, boarding, in the air or cancelled, with the new times beside the old, gate changes and the baggage belt.9 views · 12 downloads
Thread
Thread 0 posts
On FareCalendar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.