TransactionFeed
Money in and out, grouped by day, pending first, searchable, a category on each row, and the details in a sheet.
Demo
Pending-$235.10
Today-$7.25
Yesterday+$1,350.00
Fri, Oct 30-$18.20
Thu, Oct 29+$6.10
Wed, Oct 28-€7.40 · -$12.05
Tue, Oct 20-$30.00
Mon, Oct 19-$41.17
Sun, Oct 18-$14.00
Sat, Oct 17-$33.33
Wed, Dec 24, 2025-$40.00
No date
Nothing opened
Get it
It needs theme.qwe, as every component does, and price.qwe; 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/price.qwe$ curl -O https://quillolang.org/components/ui/transaction_feed.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/transaction_feed.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/transaction_feed.qwe
let transaction_feed_demo_items = [ { id: "t1", date: "2026-11-01", title: "Cafe Luz", amount: -4.50m, category: "Food and drink", account: "Everyday", time: "08:12", ref: "CL-8841" }, { id: "t2", date: "2026-11-01", title: "Hotel Sol", amount: -180m, category: "Travel", pending: true, account: "Everyday", note: "Held until you check out" }, { id: "t3", date: "2026-11-01", title: "Metro", amount: -2.75m, category: "Transport" }, { id: "t4", date: "2026-10-31", title: "Payroll", amount: "2300.00", category: "Income", note: "October" }, { id: "t5", date: "2026-10-31", title: "Harbour Lettings", amount: -950m, category: "Rent" }, { id: "t6", date: "2026-10-30", title: "Bookshop", amount: -18.20m, category: "Shopping" }, { id: "t7", date: "2026-10-29T17:30:00Z", title: "Refund: Bookshop", amount: 6.10m, category: "Shopping" }, { id: "t8", date: "2026-10-28", title: "Boulangerie", amount: -7.40m, currency: "EUR", category: "Food and drink" }, { id: "t9", date: "2026-10-28", title: "Pharmacy", amount: -12.05m, category: "Health" }, { id: "t10", date: "2026-11-01", title: "Fuel", amount: -55.10m, category: "Transport", pending: true }, { id: "t11", date: "2025-12-24", title: "Gift shop", amount: -40m, category: "Shopping" }, { id: "t12", date: "soon", title: "Mystery", amount: "abc" }, { id: "t13", date: "2026-10-20", title: "Gym", amount: -30m, category: "Health" }, { id: "t14", date: "2026-10-19", title: "Water bill", amount: -41.17m, category: "Bills" }, { id: "t15", date: "2026-10-18", title: "Cinema", amount: -14m, category: "Fun" }, { id: "t16", date: "2026-10-17", title: "Market", amount: -33.33m, category: "Food and drink" },]component TransactionFeedDemoLive() { state log = "Nothing opened" <div.demo-stack> <div.demo-phone> <TransactionFeed items={transaction_feed_demo_items} today="2026-11-01" on_open={(the_id) => { log = `Opened ${the_id}` }}/> </div> <p.demo-note id="txfeed-log">{log}</p> </div>}view TransactionFeedDemo() = <div.demo-stack><TransactionFeedDemoLive/></div>Source
ui/transaction_feed.qwe
// TransactionFeed: an account's money going in and out, as a phone shows it: what is still pending// at the top, then each day with its total, a search over the top, a category on every row, and// a tap on a row for all of it in a sheet from below. On a tablet the details stand in a pane// beside the list, so one can be read while looking through the rest.//// import ui/transaction_feed.qwe use TransactionFeed//// <div style="height: 100dvh">// <TransactionFeed today="2026-11-01" items={[// { id: "t1", date: "2026-11-01", title: "Cafe Luz", amount: -4.50m, category: "Food" },// { id: "t2", date: "2026-10-31", title: "Payroll", amount: "2300.00", category: "Income", note: "October" },// { id: "t3", date: "2026-11-01", title: "Hotel Sol", amount: -180m, category: "Travel", pending: true }]}/>// </div>//// items: each { id, date, title, amount, currency, category, pending, note, account, ref, time }.// date: a date or a date-time. amount: a decimal, an int or numeric text, negative for money// leaving; anything else shows as a dash and counts for nothing in a total. time: words for the// sheet ("14:02"). today: "YYYY-MM-DD", from your page, for Today and Yesterday. currency: for// items with none ("USD"). locale. label ("Transactions"). on_open(id): a row was opened.//// It scrolls by itself, so put it in something with a height. A person's own words are matched// as plain text. Sheet: drag it down, its handle, the X, Escape or the phone's back close it.//// transaction_feed_groups(items, today, currency, locale) and transaction_feed_match(item, words)// are the working.import ui/theme.qwe use ui_clsimport ui/price.qwe use price_textlet transaction_feed_css = css`.ui-txfeed { box-sizing: border-box; height: 100%; min-width: 0; container-type: inline-size; color: var(--ui-fg); background: var(--ui-bg); font: 15px/1.4 var(--ui-font); }.ui-txfeed-lay { display: grid; height: 100%; min-height: 0; min-width: 0; }.ui-txfeed-looking { --ui-txfeed-top: 83px; }.ui-txfeed-list { min-height: 0; min-width: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }.ui-txfeed-find { position: sticky; top: 0; z-index: 3; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px; padding: 8px 12px; background: var(--ui-bg); border-bottom: 1px solid var(--ui-border); }/* 16 px and up, or iOS zooms the page when it is focused. */.ui-txfeed-box { box-sizing: border-box; width: 100%; min-height: 44px; padding: 8px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.3 var(--ui-font); -webkit-appearance: none; appearance: none; }.ui-txfeed-box:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-txfeed-clear { width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-muted); font: 22px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-txfeed-clear:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-txfeed-said { grid-column: 1 / -1; min-height: 0; margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-txfeed-days { margin: 0; padding: 0 0 calc(12px + env(safe-area-inset-bottom)); list-style: none; }.ui-txfeed-day { position: sticky; top: var(--ui-txfeed-top, 61px); z-index: 2; display: flex; justify-content: space-between; gap: 12px; margin: 0; padding: 6px 14px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-txfeed-pending .ui-txfeed-day { color: var(--ui-warning); }.ui-txfeed-rows { margin: 0; padding: 0; list-style: none; }.ui-txfeed-row { box-sizing: border-box; display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 60px; padding: 8px 14px; border: 0; border-bottom: 1px solid var(--ui-border); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; touch-action: manipulation;}.ui-txfeed-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-txfeed-row[aria-current="true"] { background: var(--ui-accent-soft); }.ui-txfeed-mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--ui-subtle); color: var(--ui-muted); font-weight: 700; }.ui-txfeed-main { min-width: 0; }.ui-txfeed-title { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-txfeed-cat { display: block; overflow: hidden; color: var(--ui-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.ui-txfeed-amt { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-txfeed-in { color: var(--ui-success); }.ui-txfeed-none { margin: 0; padding: 28px 16px; color: var(--ui-muted); text-align: center; }.ui-txfeed-sheet { box-sizing: border-box; inset: auto 0 0 0; width: min(100%, 560px); max-width: 100%; max-height: 85dvh; margin: 0 auto; padding: 0 0 env(safe-area-inset-bottom); overflow: hidden; border: 1px solid var(--ui-border); border-bottom: 0; border-radius: calc(var(--ui-radius) + 6px) calc(var(--ui-radius) + 6px) 0 0; background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 15px/1.4 var(--ui-font); transition: transform .22s cubic-bezier(.2, .8, .2, 1);}.ui-txfeed-sheet[open] { display: flex; flex-direction: column; animation: ui-txfeed-up .24s cubic-bezier(.2, .8, .2, 1); }.ui-txfeed-sheet::backdrop { background: var(--ui-overlay); }@keyframes ui-txfeed-up { from { transform: translateY(100%); } }.ui-txfeed-dragging { transition: none; }html:has(.ui-txfeed-sheet[open]) { overflow: hidden; }.ui-txfeed-grip { display: grid; place-items: center; flex: none; width: 100%; height: 44px; padding: 0; border: 0; background: transparent; cursor: grab; touch-action: none; }.ui-txfeed-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-txfeed-grip:focus-visible { outline: none; }.ui-txfeed-grip:focus-visible span { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-txfeed-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 18px 18px; }.ui-txfeed-pane { display: none; }.ui-txfeed-head { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; gap: 4px; }.ui-txfeed-head h3 { margin: 0; font-size: 18px; outline: none; overflow-wrap: anywhere; }.ui-txfeed-x { width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-muted); font: 22px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-txfeed-x:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-txfeed-big { margin: 2px 0 12px; font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }.ui-txfeed-facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 14px; margin: 0; }.ui-txfeed-facts dt { color: var(--ui-muted); }.ui-txfeed-facts dd { margin: 0; overflow-wrap: anywhere; }/* Room for both: the sheet's place is taken by a pane, and the list keeps its scroll. */@container (min-width: 640px) { .ui-txfeed-lay { grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); } .ui-txfeed-pane { display: block; min-height: 0; overflow-y: auto; padding: 18px; border-left: 1px solid var(--ui-border); background: var(--ui-subtle); }}@media (hover: hover) { .ui-txfeed-row:hover { background: var(--ui-subtle); } }@media (prefers-reduced-motion: reduce) { .ui-txfeed-sheet { transition: none; } .ui-txfeed-sheet[open] { animation: none; } }`// An amount as a decimal, or null: bad data shows a dash and is not added up.fn transaction_feed_dec(v) { let kind = type_of(v) if kind != "dec" && kind != "int" && kind != "float" && kind != "str" { return null } let d = dec(v) is_err(d) ? null : d}// An amount with its sign always written, as money in and money out read differently.fn transaction_feed_amount(v, currency: str, locale: str?) -> str { let d = transaction_feed_dec(v) if d == null { return "—" } let text = price_text(d, currency, locale) d > 0m ? `+${text}` : text}// The currency of an item.fn transaction_feed_cur(t, fallback: str) -> str = type_of(t.currency) == "str" && t.currency != "" ? t.currency : fallback// The calendar day of an item, or "" when it has none that can be read.fn transaction_feed_day_of(t) -> str { if type_of(t.date) != "str" || !date.valid(t.date) { return "" } slice(t.date, 0, 10)}// A day in words: Today and Yesterday, else the weekday and date, with the year when it is not this one.fn transaction_feed_day_label(d: str, today: str?) -> str { if d == "" { return "No date" } if type_of(today) != "str" || !date.valid(today, "date") { return date.format(d, "ddd, MMM D, YYYY") } if d == today { return "Today" } if d == date.add_days(today, -1) { return "Yesterday" } date.format(d, slice(d, 0, 4) == slice(today, 0, 4) ? "ddd, MMM D" : "ddd, MMM D, YYYY")}// Each currency's total of some rows, exact, in the order they first come.fn transaction_feed_total(rows: list, fallback: str, locale: str?) -> str { let good = rows | filter((t) => transaction_feed_dec(t.amount) != null) let by = good | group_by((t) => transaction_feed_cur(t, fallback)) good | map((t) => transaction_feed_cur(t, fallback)) | unique() | map((c) => { let amounts = get(by, c, []) | map((t) => transaction_feed_dec(t.amount)) transaction_feed_amount(sum(amounts), c, locale) }) | join(" · ")}// Whether an item has every word of a search, in any field a person would look by.fn transaction_feed_match(t, words: list, fallback: str = "USD", locale: str? = null) -> bool { let hay = lower(`${t.title ?? ""} ${t.category ?? ""} ${t.note ?? ""} ${t.account ?? ""} ${transaction_feed_amount(t.amount, transaction_feed_cur(t, fallback), locale)}`) all(words, (w) => contains(hay, w))}// The words of a search.fn transaction_feed_words(text: str) -> list = split(lower(trim(text)), " ") | filter((w) => w != "")// What the feed shows: pending first whatever its date, then each day, newest first, in the// order given within it, and what has no date last.fn transaction_feed_groups(items: list, today: str?, fallback: str = "USD", locale: str? = null) -> list { let waiting = items | filter((t) => t.pending == true) let settled = items | filter((t) => t.pending != true) let by = settled | group_by((t) => transaction_feed_day_of(t)) let days = settled | map((t) => transaction_feed_day_of(t)) | unique() | filter((d) => d != "") | sort() | reverse() let undated = get(by, "", []) let dated = days | map((d) => { let rows = get(by, d, []) { day: d, label: transaction_feed_day_label(d, today), total: transaction_feed_total(rows, fallback, locale), rows: rows } }) let first = len(waiting) > 0 ? [{ day: "pending", label: "Pending", total: transaction_feed_total(waiting, fallback, locale), rows: waiting }] : [] let last = len(undated) > 0 ? [{ day: "none", label: "No date", total: transaction_feed_total(undated, fallback, locale), rows: undated }] : [] first + dated + last}// The letter in a row's round mark.fn transaction_feed_letter(t) -> str { let c = split(trim(str(t.category ?? t.title ?? "?")), "") len(c) == 0 ? "?" : upper(c[0])}// Whether a pull closes the sheet: a third of it, or a flick down that has moved enough to mean it.fn transaction_feed_closes(dy: num, speed: num, height: num) -> bool = dy >= height / 3 || (speed >= 0.5 && dy >= 24)// The last few points of a drag, for the speed at the release.fn transaction_feed_trail(trail: list, y: num, at_ms: num) -> list { let all_of = trail + [{ y: y, at: at_ms }] slice(all_of, max([0, len(all_of) - 6]), len(all_of))}fn transaction_feed_speed(trail: list, y: num, at_ms: num) -> num { let old = trail | filter((p) => at_ms - p.at >= 16 && at_ms - p.at <= 120) if len(old) == 0 { return 0 } (y - old[0].y) / (at_ms - old[0].at)}// All there is to say about one, in the sheet and in the pane.view TransactionFeedDetail(t, fallback: str, locale: str?, today: str?, on_close = null) = <div> <div.ui-txfeed-head> <h3 tabindex="-1">{t.title}</h3> <button.ui-txfeed-x type="button" aria-label="Close details" @click={() => { on_close() }}>×</button> </div> <p class={ui_cls(["ui-txfeed-big", transaction_feed_dec(t.amount) != null && transaction_feed_dec(t.amount) > 0m && "ui-txfeed-in"])}>{transaction_feed_amount(t.amount, transaction_feed_cur(t, fallback), locale)}</p> <dl.ui-txfeed-facts> <dt>Status</dt><dd>{t.pending == true ? "Pending: this may still change" : "Completed"}</dd> <dt>Date</dt><dd>{transaction_feed_day_label(transaction_feed_day_of(t), today)}{t.time != null ? `, ${t.time}` : ""}</dd> <dt>Category</dt><dd>{t.category != null ? t.category : "None"}</dd> {t.account != null && <dt>Account</dt>} {t.account != null && <dd>{t.account}</dd>} {t.note != null && <dt>Note</dt>} {t.note != null && <dd>{t.note}</dd>} {t.ref != null && <dt>Reference</dt>} {t.ref != null && <dd>{t.ref}</dd>} </dl> </div>component TransactionFeed(items: list = [], today: str? = null, currency: str = "USD", locale: str? = null, label: str = "Transactions", on_open = null) { style global transaction_feed_css state search = "" state chosen = null // the open item's id state wide = false // room for a pane state dlg = null state pane_el = null state opener = null // the row that opened it, for focus to return to state pushed = false // a history entry stands for the sheet state popping = false // the going back asked for by closing has not arrived yet state grab = null // where the finger went down on the handle state axis = null state trail = [] state pulled = false // a pull ended, so the click after it is not a tap let words = transaction_feed_words(search) let matching = len(words) == 0 ? items : items | filter((t) => transaction_feed_match(t, words, currency, locale)) let groups = transaction_feed_groups(matching, today, currency, locale) let open_item = items | find((t) => t.id == chosen) let shut = () => { if chosen != null { chosen = null let from = opener opener = null if from != null && wide { from.focus() } } if pushed { pushed = false popping = true js("history.back") } } let open_row = (t, e) => { opener = e.target.closest("button") chosen = t.id on_open && on_open(t.id) if !wide && !pushed { pushed = true js("history.pushState", { ui_txfeed: true }, "") } js("Promise.resolve", 1) if wide && pane_el != null { let h = pane_el.querySelector("h3") if h != null { h.focus() } } } let spring = () => { dlg.classList.remove("ui-txfeed-dragging") dlg.style.transform = "" } let finish = (e) => { if grab != null && e.pointerId == grab.id { let dy = e.clientY - grab.y let was = axis let speed = transaction_feed_speed(trail, e.clientY, e.timeStamp) grab = null axis = null spring() if was == "y" { pulled = true if e.type == "pointerup" && transaction_feed_closes(dy, speed, dlg.offsetHeight) { shut() } } } } if chosen != null && !wide && dlg != null && !dlg.open { dlg.showModal() } if (chosen == null || wide) && dlg != null && dlg.open { dlg.close() } <section.ui-txfeed aria-label={label} @mount={(el) => { let w = js("window") let measure = (_e = null) => { wide = el.clientWidth >= 640 } let back = (_e = null) => { if popping { popping = false } else if pushed { pushed = false chosen = null } } let watch = js_new("ResizeObserver", measure) watch.observe(el) w.addEventListener("popstate", back) measure() () => { watch.disconnect() w.removeEventListener("popstate", back) } }}> <div.ui-txfeed-lay> <div class={ui_cls(["ui-txfeed-list", search != "" && "ui-txfeed-looking"])}> <div.ui-txfeed-find role="search"> <input.ui-txfeed-box type="search" aria-label="Search transactions" placeholder="Search" enterkeyhint="search" autocomplete="off" bind:value={search} @keydown={(e) => { if e.key == "Enter" { e.target.blur() } }}/> {search != "" && <button.ui-txfeed-clear type="button" aria-label="Clear search" @click={() => { search = "" }}>×</button>} {search != "" && <p.ui-txfeed-said role="status">{len(matching) == 1 ? "1 found" : `${len(matching)} found`}</p>} </div> {len(items) == 0 && <p.ui-txfeed-none>No transactions yet.</p>} {len(items) > 0 && len(matching) == 0 && <p.ui-txfeed-none>Nothing matches that.</p>} <ol.ui-txfeed-days> {for g in groups => <li key={g.day} class={g.day == "pending" ? "ui-txfeed-pending" : null}> <h3.ui-txfeed-day><span>{g.label}</span><span>{g.total}</span></h3> <ul.ui-txfeed-rows> {for t in g.rows => <li key={t.id}> <button.ui-txfeed-row type="button" aria-haspopup="dialog" aria-current={chosen == t.id ? "true" : null} @click={(e) => { open_row(t, e) }}> <span.ui-txfeed-mark aria-hidden="true">{transaction_feed_letter(t)}</span> <span.ui-txfeed-main> <span.ui-txfeed-title>{t.title}</span> <span.ui-txfeed-cat>{t.category != null ? t.category : "No category"}{t.pending == true ? " · Pending" : ""}</span> </span> <span class={ui_cls(["ui-txfeed-amt", transaction_feed_dec(t.amount) != null && transaction_feed_dec(t.amount) > 0m && "ui-txfeed-in"])}>{transaction_feed_amount(t.amount, transaction_feed_cur(t, currency), locale)}</span> </button> </li>} </ul> </li>} </ol> </div> <aside.ui-txfeed-pane aria-label="Details" @mount={(el) => { pane_el = el }}> {wide && open_item != null && <TransactionFeedDetail t={open_item} fallback={currency} locale={locale} today={today} on_close={shut}/>} {open_item == null && <p.ui-txfeed-none>Choose a transaction to see it here.</p>} </aside> </div> <dialog.ui-txfeed-sheet aria-label="Transaction details" @mount={(el) => { dlg = el }} @close={() => { if !wide { shut() } }} @click={(e) => { if e.target.tagName == "DIALOG" { shut() } }}> <button.ui-txfeed-grip type="button" aria-label="Close details" @pointerdown={(e) => { if e.button == 0 && grab == null { e.target.closest("button").setPointerCapture(e.pointerId) grab = { y: e.clientY, x: e.clientX, id: e.pointerId } trail = transaction_feed_trail([], e.clientY, e.timeStamp) axis = null pulled = false } }} @pointermove={(e) => { if grab != null && e.pointerId == grab.id { let dy = e.clientY - grab.y let dx = e.clientX - grab.x if axis == null && (abs(dy) > 6 || abs(dx) > 6) { axis = abs(dy) >= abs(dx) ? "y" : "x" if axis == "y" { dlg.classList.add("ui-txfeed-dragging") } } if axis == "y" { dlg.style.transform = `translateY(${max([0, dy])}px)` trail = transaction_feed_trail(trail, e.clientY, e.timeStamp) } } }} @pointerup={finish} @pointercancel={finish} @click={() => { if pulled { pulled = false } else { shut() } }}><span></span></button> <div.ui-txfeed-body> {!wide && open_item != null && <TransactionFeedDetail t={open_item} fallback={currency} locale={locale} today={today} on_close={shut}/>} </div> </dialog> </section>}Related
ActionSheetA 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 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 downloadsAnchoredPopoverA popover with an arrow tied to its button on a tablet and a sheet on a phone, re-anchored on rotation and when a keyboard opens.3 views · 5 downloadsBalanceCardBalances that hide with a tap and show for a hold, swiped between accounts, in decimals, with when they were read.3 views · 6 downloadsBillRemindersUpcoming bills in order, marked when close, to pay or snooze, with the ends of months handled.3 views · 6 downloadsCardStackCards in a fan: a tap lifts one, a switch freezes it, and its details are hidden, shown and copied a field at a time.2 views · 9 downloads
Thread
Thread 0 posts
On TransactionFeed: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.